From 757fd3125d0559030cc709366d0367d246b4c15b Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 1 Jul 2026 06:12:08 +0300 Subject: [PATCH] Add shadow security finding inbox --- adk-rust/Cargo.lock | 1 - adk-rust/Cargo.toml | 2 + adk-rust/crates/aw-1c-ingest/src/main.rs | 4 + adk-rust/crates/containment-engine/Cargo.toml | 15 + .../crates/containment-engine/src/main.rs | 1255 +++++++++++ adk-rust/crates/hayabusa-tools/Cargo.toml | 2 + .../hayabusa-tools/src/bin/autoprocess.rs | 304 ++- .../crates/security-finding-inbox/Cargo.toml | 20 + .../crates/security-finding-inbox/src/main.rs | 1968 +++++++++++++++++ aw-server/hayabusa/README.md | 48 +- aw-server/hayabusa/aw-hayabusa.sh | 66 +- clickhouse-1c/README.md | 3 + clickhouse-1c/docker-compose.yml | 10 + clickhouse-1c/ops/run_ingest_cycle.sh | 8 + .../security/security_finding_inbox.sql | 97 + configs/containment-finding.example.json | 21 + configs/containment-policy.example.json | 17 + .../security/security-finding.example.json | 21 + ...-firewall-containment-request.example.json | 18 + docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md | 194 ++ docs/CONTAINMENT_POLICY_RU.md | 146 ++ ...ST_SIGMA_HAYABUSA_VELOCIRAPTOR_ADDON_RU.md | 813 +++++++ docs/SECURITY_FINDING_INBOX_RU.md | 279 +++ docs/wiki/Hayabusa-Security-Analytics.md | 2 +- .../aw-security-finding-executor.service | 21 + scripts/containment_shadow_smoke.sh | 98 + scripts/security_finding_inbox_smoke.sh | 110 + 27 files changed, 5524 insertions(+), 19 deletions(-) create mode 100644 adk-rust/crates/containment-engine/Cargo.toml create mode 100644 adk-rust/crates/containment-engine/src/main.rs create mode 100644 adk-rust/crates/security-finding-inbox/Cargo.toml create mode 100644 adk-rust/crates/security-finding-inbox/src/main.rs create mode 100644 clickhouse-1c/security/security_finding_inbox.sql create mode 100644 configs/containment-finding.example.json create mode 100644 configs/containment-policy.example.json create mode 100644 configs/security/security-finding.example.json create mode 100644 configs/windows-firewall-containment-request.example.json create mode 100644 docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md create mode 100644 docs/CONTAINMENT_POLICY_RU.md create mode 100644 docs/LOW_COST_SIGMA_HAYABUSA_VELOCIRAPTOR_ADDON_RU.md create mode 100644 docs/SECURITY_FINDING_INBOX_RU.md create mode 100644 ops/systemd/aw-security-finding-executor.service create mode 100644 scripts/containment_shadow_smoke.sh create mode 100644 scripts/security_finding_inbox_smoke.sh diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index 51c9b7c..d4d490c 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -710,7 +710,6 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "serde_json", ] [[package]] diff --git a/adk-rust/Cargo.toml b/adk-rust/Cargo.toml index 31a587e..b68059b 100644 --- a/adk-rust/Cargo.toml +++ b/adk-rust/Cargo.toml @@ -36,6 +36,7 @@ members = [ "crates/aw-rus-healthd", "crates/detmir-check", "crates/detmir-core", + "crates/security-finding-inbox", "crates/dlp-health-check", "crates/dlp-content-analyzer", "crates/dlp-admin-cli", @@ -57,6 +58,7 @@ members = [ "crates/detmir-heal-safe", "crates/detmir-status", "crates/detmir-state", + "crates/containment-engine", "crates/tsj-guardian-status", "crates/tsj-guardian-watchdog", ] diff --git a/adk-rust/crates/aw-1c-ingest/src/main.rs b/adk-rust/crates/aw-1c-ingest/src/main.rs index 03cba5e..f2ca4f0 100644 --- a/adk-rust/crates/aw-1c-ingest/src/main.rs +++ b/adk-rust/crates/aw-1c-ingest/src/main.rs @@ -176,6 +176,10 @@ fn run() -> Result { &root.join("detections/open_cases_from_detections.sql"), &mut summary, )?; + let security_inbox_schema = root.join("security/security_finding_inbox.sql"); + if security_inbox_schema.exists() { + run_sql_file(&client, &security_inbox_schema, &mut summary)?; + } if !cli.skip_briefs { let _ = run_optional_script(&root.join("ops/run_manager_brief.sh")); let _ = run_optional_script(&root.join("ops/run_recovery_brief.sh")); diff --git a/adk-rust/crates/containment-engine/Cargo.toml b/adk-rust/crates/containment-engine/Cargo.toml new file mode 100644 index 0000000..18ecb06 --- /dev/null +++ b/adk-rust/crates/containment-engine/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "containment-engine" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true diff --git a/adk-rust/crates/containment-engine/src/main.rs b/adk-rust/crates/containment-engine/src/main.rs new file mode 100644 index 0000000..10891e9 --- /dev/null +++ b/adk-rust/crates/containment-engine/src/main.rs @@ -0,0 +1,1255 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use chrono::{SecondsFormat, Utc}; +use clap::{Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Parser)] +#[command(about = "AWatch-rus containment decision engine")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Evaluate a finding against containment policy without mutating hosts. + Decide { + #[arg( + long, + env = "AW_CONTAINMENT_POLICY", + default_value = "/etc/activitywatch/containment-policy.json" + )] + policy: PathBuf, + + #[arg(long)] + finding: PathBuf, + + #[arg(long)] + pretty: bool, + }, + /// Print an example conservative policy. + SamplePolicy { + #[arg(long)] + pretty: bool, + }, + /// Print an example high-confidence workstation finding. + SampleFinding { + #[arg(long)] + pretty: bool, + }, + /// Windows Firewall executor interface: plan/apply/verify/rollback. + WindowsFirewall { + #[command(subcommand)] + action: WindowsFirewallCommand, + }, + /// Print an example Windows Firewall containment request. + SampleWindowsFirewallRequest { + #[arg(long)] + pretty: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum WindowsFirewallCommand { + /// Build a Windows Firewall containment plan from a request. + Plan { + #[arg(long)] + request: PathBuf, + + #[arg(long)] + pretty: bool, + }, + /// Apply a plan on the local Windows host when explicitly confirmed. + Apply { + #[arg(long)] + plan: PathBuf, + + #[arg(long, default_value = "NO")] + confirm_apply: String, + + #[arg(long)] + execute_local: bool, + + #[arg(long)] + pretty: bool, + }, + /// Verify the plan on the local Windows host, or print verification commands. + Verify { + #[arg(long)] + plan: PathBuf, + + #[arg(long)] + execute_local: bool, + + #[arg(long)] + pretty: bool, + }, + /// Roll back a previously applied Windows Firewall plan. + Rollback { + #[arg(long)] + plan: PathBuf, + + #[arg(long, default_value = "NO")] + confirm_rollback: String, + + #[arg(long)] + execute_local: bool, + + #[arg(long)] + pretty: bool, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum Mode { + Disabled, + Shadow, + ManualApproval, + Auto, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum HostRole { + Workstation, + Server, + DomainController, + Unknown, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum Confidence { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ContainmentAction { + WindowsFirewallQuarantine, + PfsenseHostBlock, + SwitchVlanQuarantine, + DisableWorkstationAccount, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Policy { + enabled: bool, + mode: Mode, + default_ttl_minutes: u32, + require_admin_channel_check: bool, + allow_auto_for_servers: bool, + allowed_actions: Vec, + management_allowlist: Vec, + minimum_high_signals_for_auto: usize, +} + +impl Default for Policy { + fn default() -> Self { + Self { + enabled: false, + mode: Mode::Shadow, + default_ttl_minutes: 60, + require_admin_channel_check: true, + allow_auto_for_servers: false, + allowed_actions: vec![ + ContainmentAction::WindowsFirewallQuarantine, + ContainmentAction::PfsenseHostBlock, + ], + management_allowlist: vec![ + "aw_server".to_string(), + "velociraptor_server".to_string(), + "admin_jump_host".to_string(), + ], + minimum_high_signals_for_auto: 2, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Signal { + source: String, + rule_id: String, + confidence: Confidence, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Finding { + host: String, + host_role: HostRole, + state: String, + confidence: Confidence, + signals: Vec, + recommended_action: Option, + management_channel_checked: bool, + manual_operator_flag: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct Decision { + ok: bool, + generated_at_utc: String, + host: String, + host_role: HostRole, + policy_mode: Mode, + decision_status: String, + recommended_action: Option, + ttl_minutes: u32, + rollback_plan_id: Option, + would_mutate: bool, + blockers: Vec, + audit: Audit, +} + +#[derive(Debug, Clone, Serialize)] +struct Audit { + signals_total: usize, + critical_signals: usize, + high_signals: usize, + management_channel_checked: bool, + management_allowlist_count: usize, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WindowsFirewallRequest { + target_host: String, + plan_id: String, + ttl_minutes: u32, + reason: String, + management_allowlist: Vec, + blocked_remote_addresses: Vec, + profiles: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WindowsFirewallPlan { + executor: String, + plan_id: String, + generated_at_utc: String, + target_host: String, + ttl_minutes: u32, + reason: String, + rule_group: String, + safety_model: String, + requires_manual_confirmation: bool, + allow_rules: Vec, + block_rules: Vec, + apply_commands: Vec, + verify_commands: Vec, + rollback_commands: Vec, + blockers: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct FirewallRule { + display_name: String, + direction: FirewallDirection, + action: FirewallAction, + remote_address: String, + profile: String, + description: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum FirewallDirection { + Inbound, + Outbound, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum FirewallAction { + Allow, + Block, +} + +#[derive(Debug, Clone, Serialize)] +struct ExecutorResult { + executor: String, + operation: String, + generated_at_utc: String, + plan_id: String, + target_host: String, + ok: bool, + execution_status: String, + would_mutate: bool, + execute_local_requested: bool, + commands: Vec, + stdout: Vec, + stderr: Vec, + blockers: Vec, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Decide { + policy, + finding, + pretty, + } => { + let policy = read_json::(&policy).with_context(|| { + format!("failed to read containment policy {}", policy.display()) + })?; + let finding = read_json::(&finding) + .with_context(|| format!("failed to read finding {}", finding.display()))?; + print_json(&decide(&policy, &finding), pretty) + } + Command::SamplePolicy { pretty } => print_json(&Policy::default(), pretty), + Command::SampleFinding { pretty } => print_json(&sample_finding(), pretty), + Command::WindowsFirewall { action } => match action { + WindowsFirewallCommand::Plan { request, pretty } => { + let request = read_json::(&request).with_context(|| { + format!( + "failed to read Windows Firewall request {}", + request.display() + ) + })?; + print_json(&build_windows_firewall_plan(&request), pretty) + } + WindowsFirewallCommand::Apply { + plan, + confirm_apply, + execute_local, + pretty, + } => { + let plan = read_json::(&plan).with_context(|| { + format!("failed to read Windows Firewall plan {}", plan.display()) + })?; + print_json( + &run_windows_firewall_executor(&plan, "apply", &confirm_apply, execute_local)?, + pretty, + ) + } + WindowsFirewallCommand::Verify { + plan, + execute_local, + pretty, + } => { + let plan = read_json::(&plan).with_context(|| { + format!("failed to read Windows Firewall plan {}", plan.display()) + })?; + print_json( + &run_windows_firewall_executor(&plan, "verify", "YES", execute_local)?, + pretty, + ) + } + WindowsFirewallCommand::Rollback { + plan, + confirm_rollback, + execute_local, + pretty, + } => { + let plan = read_json::(&plan).with_context(|| { + format!("failed to read Windows Firewall plan {}", plan.display()) + })?; + print_json( + &run_windows_firewall_executor( + &plan, + "rollback", + &confirm_rollback, + execute_local, + )?, + pretty, + ) + } + }, + Command::SampleWindowsFirewallRequest { pretty } => { + print_json(&sample_windows_firewall_request(), pretty) + } + } +} + +fn read_json(path: &PathBuf) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let data = fs::read(path)?; + serde_json::from_slice(&data).context("invalid JSON") +} + +fn print_json(value: &T, pretty: bool) -> Result<()> { + if pretty { + println!("{}", serde_json::to_string_pretty(value)?); + } else { + println!("{}", serde_json::to_string(value)?); + } + Ok(()) +} + +fn decide(policy: &Policy, finding: &Finding) -> Decision { + let mut blockers = Vec::new(); + let audit = audit(policy, finding); + let action = finding + .recommended_action + .clone() + .or_else(|| policy.allowed_actions.first().cloned()); + + if !policy.enabled || policy.mode == Mode::Disabled { + return decision(policy, finding, "disabled", action, false, blockers, audit); + } + + if !is_suspected_state(&finding.state) && !finding.manual_operator_flag { + blockers.push(format!("finding_state_not_actionable:{}", finding.state)); + } + if !meets_signal_threshold(policy, finding, &audit) { + blockers.push("signal_threshold_not_met".to_string()); + } + if let Some(action) = &action { + if !policy.allowed_actions.contains(action) { + blockers.push(format!("action_not_allowed:{action:?}")); + } + } else { + blockers.push("no_allowed_action".to_string()); + } + if policy.require_admin_channel_check && !finding.management_channel_checked { + blockers.push("management_channel_not_checked".to_string()); + } + if policy.require_admin_channel_check && policy.management_allowlist.is_empty() { + blockers.push("management_allowlist_empty".to_string()); + } + + match policy.mode { + Mode::Disabled => decision(policy, finding, "disabled", action, false, blockers, audit), + Mode::Shadow => decision( + policy, + finding, + if blockers.is_empty() { + "shadow_recommended" + } else { + "shadow_blocked" + }, + action, + false, + blockers, + audit, + ), + Mode::ManualApproval => decision( + policy, + finding, + if blockers.is_empty() { + "manual_approval_required" + } else { + "manual_approval_blocked" + }, + action, + false, + blockers, + audit, + ), + Mode::Auto => { + if !policy.allow_auto_for_servers && finding.host_role != HostRole::Workstation { + blockers.push(format!("auto_refuses_host_role:{:?}", finding.host_role)); + } + let status = if blockers.is_empty() { + "auto_ready" + } else { + "auto_refused" + }; + decision(policy, finding, status, action, false, blockers, audit) + } + } +} + +fn decision( + policy: &Policy, + finding: &Finding, + status: &str, + action: Option, + would_mutate: bool, + blockers: Vec, + audit: Audit, +) -> Decision { + Decision { + ok: blockers.is_empty(), + generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + host: finding.host.clone(), + host_role: finding.host_role.clone(), + policy_mode: policy.mode.clone(), + decision_status: status.to_string(), + recommended_action: action, + ttl_minutes: policy.default_ttl_minutes, + rollback_plan_id: Some(rollback_plan_id(policy, finding)), + would_mutate, + blockers, + audit, + } +} + +fn audit(policy: &Policy, finding: &Finding) -> Audit { + Audit { + signals_total: finding.signals.len(), + critical_signals: finding + .signals + .iter() + .filter(|signal| signal.confidence == Confidence::Critical) + .count(), + high_signals: finding + .signals + .iter() + .filter(|signal| matches!(signal.confidence, Confidence::High | Confidence::Critical)) + .count(), + management_channel_checked: finding.management_channel_checked, + management_allowlist_count: policy.management_allowlist.len(), + } +} + +fn meets_signal_threshold(policy: &Policy, finding: &Finding, audit: &Audit) -> bool { + finding.manual_operator_flag + || matches!(finding.confidence, Confidence::Critical) + || audit.critical_signals > 0 + || audit.high_signals >= policy.minimum_high_signals_for_auto +} + +fn is_suspected_state(state: &str) -> bool { + matches!( + state.trim().to_ascii_lowercase().as_str(), + "suspected_infected" | "confirmed_infected" + ) +} + +fn rollback_plan_id(policy: &Policy, finding: &Finding) -> String { + let mut hasher = Sha256::new(); + hasher.update(finding.host.as_bytes()); + hasher.update(format!("{:?}", finding.host_role).as_bytes()); + hasher.update(format!("{:?}", policy.mode).as_bytes()); + hasher.update(policy.default_ttl_minutes.to_be_bytes()); + format!("rollback-{:x}", hasher.finalize())[..25].to_string() +} + +fn build_windows_firewall_plan(request: &WindowsFirewallRequest) -> WindowsFirewallPlan { + let mut blockers = validate_windows_firewall_request(request); + let plan_id = request.plan_id.trim().to_ascii_lowercase(); + let target_host = request.target_host.trim().to_string(); + let rule_group = format!("AWatch-rus containment {plan_id}"); + let profiles = normalized_profiles(&request.profiles); + + let mut allow_rules = Vec::new(); + for remote in &request.management_allowlist { + for profile in &profiles { + allow_rules.push(firewall_rule( + &plan_id, + "allow-admin-in", + FirewallDirection::Inbound, + FirewallAction::Allow, + remote, + profile, + "Keep the management channel reachable during containment.", + )); + allow_rules.push(firewall_rule( + &plan_id, + "allow-admin-out", + FirewallDirection::Outbound, + FirewallAction::Allow, + remote, + profile, + "Keep the management channel reachable during containment.", + )); + } + } + + let mut block_rules = Vec::new(); + for remote in &request.blocked_remote_addresses { + for profile in &profiles { + block_rules.push(firewall_rule( + &plan_id, + "block-suspect-in", + FirewallDirection::Inbound, + FirewallAction::Block, + remote, + profile, + "Block suspected lateral movement to or from explicit remote ranges.", + )); + block_rules.push(firewall_rule( + &plan_id, + "block-suspect-out", + FirewallDirection::Outbound, + FirewallAction::Block, + remote, + profile, + "Block suspected lateral movement to or from explicit remote ranges.", + )); + } + } + + if !blockers.is_empty() { + blockers.sort(); + blockers.dedup(); + } + + let apply_commands = allow_rules + .iter() + .chain(block_rules.iter()) + .map(|rule| new_firewall_rule_command(&rule_group, rule)) + .collect::>(); + let verify_commands = vec![format!( + "Get-NetFirewallRule -Group {} | Select-Object DisplayName,Enabled,Direction,Action,Profile", + ps_quote(&rule_group) + )]; + let rollback_commands = vec![format!( + "Remove-NetFirewallRule -Group {}", + ps_quote(&rule_group) + )]; + + WindowsFirewallPlan { + executor: "windows_firewall".to_string(), + plan_id, + generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + target_host, + ttl_minutes: request.ttl_minutes, + reason: request.reason.trim().to_string(), + rule_group, + safety_model: + "explicit_allow_management_then_explicit_block_ranges_no_default_profile_change" + .to_string(), + requires_manual_confirmation: true, + allow_rules, + block_rules, + apply_commands, + verify_commands, + rollback_commands, + blockers, + } +} + +fn validate_windows_firewall_request(request: &WindowsFirewallRequest) -> Vec { + let mut blockers = Vec::new(); + if !is_safe_identifier(&request.target_host, 1, 128) { + blockers.push("target_host_invalid".to_string()); + } + if !is_safe_identifier(&request.plan_id, 8, 64) { + blockers.push("plan_id_invalid".to_string()); + } + if request.ttl_minutes == 0 || request.ttl_minutes > 24 * 60 { + blockers.push("ttl_minutes_must_be_1_to_1440".to_string()); + } + if request.reason.trim().len() < 8 || request.reason.len() > 240 { + blockers.push("reason_length_invalid".to_string()); + } + if request.management_allowlist.is_empty() { + blockers.push("management_allowlist_empty".to_string()); + } + if request.blocked_remote_addresses.is_empty() { + blockers.push("blocked_remote_addresses_empty".to_string()); + } + if request.management_allowlist.len() > 32 { + blockers.push("management_allowlist_too_large".to_string()); + } + if request.blocked_remote_addresses.len() > 64 { + blockers.push("blocked_remote_addresses_too_large".to_string()); + } + + for remote in &request.management_allowlist { + if !is_safe_remote_address(remote) { + blockers.push(format!("management_address_invalid:{remote}")); + } + } + for remote in &request.blocked_remote_addresses { + if !is_safe_remote_address(remote) { + blockers.push(format!("blocked_address_invalid:{remote}")); + } + if is_broad_block(remote) { + blockers.push(format!("broad_block_refused:{remote}")); + } + } + for profile in &request.profiles { + if !is_allowed_profile(profile) { + blockers.push(format!("profile_invalid:{profile}")); + } + } + if request.blocked_remote_addresses.iter().any(|blocked| { + request + .management_allowlist + .iter() + .any(|allow| remote_addresses_overlap(blocked, allow)) + }) { + blockers.push("management_allowlist_overlaps_blocked_remote_addresses".to_string()); + } + blockers +} + +fn normalized_profiles(profiles: &[String]) -> Vec { + if profiles.is_empty() { + return vec!["Any".to_string()]; + } + profiles + .iter() + .map(|profile| { + let profile = profile.trim(); + let mut chars = profile.chars(); + match chars.next() { + Some(first) => { + first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase() + } + None => "Any".to_string(), + } + }) + .collect() +} + +fn firewall_rule( + plan_id: &str, + label: &str, + direction: FirewallDirection, + action: FirewallAction, + remote_address: &str, + profile: &str, + description: &str, +) -> FirewallRule { + let direction_label = match direction { + FirewallDirection::Inbound => "in", + FirewallDirection::Outbound => "out", + }; + let action_label = match action { + FirewallAction::Allow => "allow", + FirewallAction::Block => "block", + }; + let remote_label = safe_display_token(remote_address); + FirewallRule { + display_name: format!( + "AWatch containment {plan_id} {action_label}-{direction_label} {label} {remote_label}" + ), + direction, + action, + remote_address: remote_address.trim().to_string(), + profile: profile.to_string(), + description: description.to_string(), + } +} + +fn new_firewall_rule_command(group: &str, rule: &FirewallRule) -> String { + format!( + "New-NetFirewallRule -DisplayName {} -Group {} -Direction {} -Action {} -RemoteAddress {} -Profile {} -Enabled True -Description {}", + ps_quote(&rule.display_name), + ps_quote(group), + match rule.direction { + FirewallDirection::Inbound => "Inbound", + FirewallDirection::Outbound => "Outbound", + }, + match rule.action { + FirewallAction::Allow => "Allow", + FirewallAction::Block => "Block", + }, + ps_quote(&rule.remote_address), + ps_quote(&rule.profile), + ps_quote(&rule.description), + ) +} + +fn run_windows_firewall_executor( + plan: &WindowsFirewallPlan, + operation: &str, + confirmation: &str, + execute_local: bool, +) -> Result { + validate_windows_firewall_plan(plan)?; + let commands = match operation { + "apply" => &plan.apply_commands, + "verify" => &plan.verify_commands, + "rollback" => &plan.rollback_commands, + _ => bail!("unsupported Windows Firewall executor operation: {operation}"), + }; + let mut blockers = plan.blockers.clone(); + if operation == "apply" && confirmation != "YES" { + blockers.push("confirm_apply_must_be_YES".to_string()); + } + if operation == "rollback" && confirmation != "YES" { + blockers.push("confirm_rollback_must_be_YES".to_string()); + } + + if !blockers.is_empty() { + return Ok(executor_result( + plan, + operation, + ExecutorRunData { + execution_status: "refused".to_string(), + would_mutate: false, + execute_local_requested: execute_local, + commands: commands.clone(), + stdout: Vec::new(), + stderr: Vec::new(), + blockers, + }, + )); + } + + if !execute_local { + return Ok(executor_result( + plan, + operation, + ExecutorRunData { + execution_status: "dry_run_commands_ready".to_string(), + would_mutate: false, + execute_local_requested: execute_local, + commands: commands.clone(), + stdout: Vec::new(), + stderr: Vec::new(), + blockers: Vec::new(), + }, + )); + } + + let executed = execute_powershell_commands(commands, operation != "verify")?; + Ok(executor_result( + plan, + operation, + ExecutorRunData { + execution_status: executed.status, + would_mutate: executed.would_mutate, + execute_local_requested: execute_local, + commands: commands.clone(), + stdout: executed.stdout, + stderr: executed.stderr, + blockers: executed.blockers, + }, + )) +} + +struct LocalExecution { + status: String, + would_mutate: bool, + stdout: Vec, + stderr: Vec, + blockers: Vec, +} + +struct ExecutorRunData { + execution_status: String, + would_mutate: bool, + execute_local_requested: bool, + commands: Vec, + stdout: Vec, + stderr: Vec, + blockers: Vec, +} + +#[cfg(windows)] +fn execute_powershell_commands( + commands: &[String], + mutation_expected: bool, +) -> Result { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + for command in commands { + let output = std::process::Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-Command") + .arg(command) + .output() + .with_context(|| format!("failed to execute PowerShell command: {command}"))?; + stdout.push(String::from_utf8_lossy(&output.stdout).trim().to_string()); + stderr.push(String::from_utf8_lossy(&output.stderr).trim().to_string()); + if !output.status.success() { + return Ok(LocalExecution { + status: "failed".to_string(), + would_mutate: mutation_expected, + stdout, + stderr, + blockers: vec![format!("powershell_exit_status:{}", output.status)], + }); + } + } + Ok(LocalExecution { + status: "executed_local_windows".to_string(), + would_mutate: mutation_expected, + stdout, + stderr, + blockers: Vec::new(), + }) +} + +#[cfg(not(windows))] +fn execute_powershell_commands( + _commands: &[String], + _mutation_expected: bool, +) -> Result { + Ok(LocalExecution { + status: "refused_non_windows_host".to_string(), + would_mutate: false, + stdout: Vec::new(), + stderr: Vec::new(), + blockers: vec!["execute_local_requires_windows_host".to_string()], + }) +} + +fn executor_result( + plan: &WindowsFirewallPlan, + operation: &str, + run: ExecutorRunData, +) -> ExecutorResult { + ExecutorResult { + executor: plan.executor.clone(), + operation: operation.to_string(), + generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + plan_id: plan.plan_id.clone(), + target_host: plan.target_host.clone(), + ok: run.blockers.is_empty(), + execution_status: run.execution_status, + would_mutate: run.would_mutate, + execute_local_requested: run.execute_local_requested, + commands: run.commands, + stdout: run.stdout, + stderr: run.stderr, + blockers: run.blockers, + } +} + +fn validate_windows_firewall_plan(plan: &WindowsFirewallPlan) -> Result<()> { + if plan.executor != "windows_firewall" { + bail!("plan executor must be windows_firewall"); + } + if !is_safe_identifier(&plan.plan_id, 8, 64) { + bail!("plan_id_invalid"); + } + if !is_safe_identifier(&plan.target_host, 1, 128) { + bail!("target_host_invalid"); + } + if plan.rule_group.trim().is_empty() + || plan.rule_group.contains('\r') + || plan.rule_group.contains('\n') + { + bail!("rule_group_invalid"); + } + for rule in plan.allow_rules.iter().chain(plan.block_rules.iter()) { + if rule.display_name.contains('\r') + || rule.display_name.contains('\n') + || rule.display_name.len() > 180 + { + bail!("rule_display_name_invalid"); + } + if !is_safe_remote_address(&rule.remote_address) { + bail!("rule_remote_address_invalid:{}", rule.remote_address); + } + if rule.action == FirewallAction::Block && is_broad_block(&rule.remote_address) { + bail!("broad block refused in plan: {}", rule.remote_address); + } + if !is_allowed_profile(&rule.profile) { + bail!("rule_profile_invalid:{}", rule.profile); + } + } + Ok(()) +} + +fn is_safe_identifier(value: &str, min: usize, max: usize) -> bool { + let trimmed = value.trim(); + (min..=max).contains(&trimmed.len()) + && trimmed + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn is_safe_remote_address(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() + && trimmed.len() <= 96 + && trimmed + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'/' | b'-')) +} + +fn is_allowed_profile(profile: &str) -> bool { + matches!( + profile.trim().to_ascii_lowercase().as_str(), + "any" | "domain" | "private" | "public" + ) +} + +fn is_broad_block(remote: &str) -> bool { + matches!( + remote.trim().to_ascii_lowercase().as_str(), + "any" | "*" | "localsubnet" | "internet" | "intranet" + ) +} + +fn same_token(left: &str, right: &str) -> bool { + left.trim().eq_ignore_ascii_case(right.trim()) +} + +fn remote_addresses_overlap(blocked: &str, allowed: &str) -> bool { + if same_token(blocked, allowed) { + return true; + } + match (ipv4_network(blocked), ipv4_network(allowed)) { + (Some((blocked_addr, blocked_prefix)), Some((allowed_addr, allowed_prefix))) => { + let prefix = blocked_prefix.min(allowed_prefix); + ipv4_network_base(blocked_addr, prefix) == ipv4_network_base(allowed_addr, prefix) + } + _ => false, + } +} + +fn ipv4_network(value: &str) -> Option<(u32, u8)> { + let trimmed = value.trim(); + let (addr, prefix) = if let Some((addr, prefix)) = trimmed.split_once('/') { + let prefix = prefix.parse::().ok()?; + if prefix > 32 { + return None; + } + (addr, prefix) + } else { + (trimmed, 32) + }; + let addr = addr.parse::().ok()?; + Some((u32::from(addr), prefix)) +} + +fn ipv4_network_base(addr: u32, prefix: u8) -> u32 { + if prefix == 0 { + return 0; + } + let mask = u32::MAX << (32 - prefix); + addr & mask +} + +fn safe_display_token(value: &str) -> String { + value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + ch + } else { + '_' + } + }) + .collect() +} + +fn ps_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn sample_finding() -> Finding { + Finding { + host: "HOST-EXAMPLE".to_string(), + host_role: HostRole::Workstation, + state: "suspected_infected".to_string(), + confidence: Confidence::High, + signals: vec![ + Signal { + source: "hayabusa".to_string(), + rule_id: "sigma-placeholder-critical".to_string(), + confidence: Confidence::Critical, + }, + Signal { + source: "velociraptor".to_string(), + rule_id: "Windows.Hayabusa.Monitoring".to_string(), + confidence: Confidence::High, + }, + ], + recommended_action: Some(ContainmentAction::WindowsFirewallQuarantine), + management_channel_checked: true, + manual_operator_flag: false, + } +} + +fn sample_windows_firewall_request() -> WindowsFirewallRequest { + WindowsFirewallRequest { + target_host: "HOST-EXAMPLE".to_string(), + plan_id: "rollback-host-example-001".to_string(), + ttl_minutes: 60, + reason: "High-confidence Hayabusa and Velociraptor containment drill".to_string(), + management_allowlist: vec![ + "10.10.10.10".to_string(), + "10.10.10.11".to_string(), + "10.10.10.12".to_string(), + ], + blocked_remote_addresses: vec!["10.10.20.0/24".to_string(), "10.10.30.0/24".to_string()], + profiles: vec!["Domain".to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn enabled_policy(mode: Mode) -> Policy { + Policy { + enabled: true, + mode, + ..Policy::default() + } + } + + #[test] + fn disabled_policy_never_mutates() { + let policy = Policy::default(); + let decision = decide(&policy, &sample_finding()); + assert_eq!(decision.decision_status, "disabled"); + assert!(!decision.would_mutate); + assert!(decision.ok); + } + + #[test] + fn shadow_recommends_without_mutation() { + let decision = decide(&enabled_policy(Mode::Shadow), &sample_finding()); + assert_eq!(decision.decision_status, "shadow_recommended"); + assert!(!decision.would_mutate); + assert!(decision.ok); + } + + #[test] + fn auto_refuses_server_by_default() { + let mut finding = sample_finding(); + finding.host_role = HostRole::Server; + let decision = decide(&enabled_policy(Mode::Auto), &finding); + assert_eq!(decision.decision_status, "auto_refused"); + assert!( + decision + .blockers + .iter() + .any(|blocker| blocker.starts_with("auto_refuses_host_role")) + ); + } + + #[test] + fn auto_requires_management_channel() { + let mut finding = sample_finding(); + finding.management_channel_checked = false; + let decision = decide(&enabled_policy(Mode::Auto), &finding); + assert_eq!(decision.decision_status, "auto_refused"); + assert!( + decision + .blockers + .contains(&"management_channel_not_checked".to_string()) + ); + } + + #[test] + fn weak_signal_is_blocked() { + let mut finding = sample_finding(); + finding.confidence = Confidence::Medium; + finding.signals = vec![Signal { + source: "hayabusa".to_string(), + rule_id: "weak".to_string(), + confidence: Confidence::Medium, + }]; + let decision = decide(&enabled_policy(Mode::ManualApproval), &finding); + assert_eq!(decision.decision_status, "manual_approval_blocked"); + assert!( + decision + .blockers + .contains(&"signal_threshold_not_met".to_string()) + ); + } + + #[test] + fn unknown_policy_field_is_rejected() { + let json = r#"{ + "enabled": false, + "mode": "shadow", + "default_ttl_minutes": 60, + "require_admin_channel_check": true, + "allow_auto_for_servers": false, + "allowed_actions": ["windows_firewall_quarantine"], + "management_allowlist": ["aw_server"], + "minimum_high_signals_for_auto": 2, + "unexpected_auto_bypass": true + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn windows_firewall_plan_generates_commands_and_rollback() { + let request = sample_windows_firewall_request(); + let plan = build_windows_firewall_plan(&request); + + assert!(plan.blockers.is_empty()); + assert_eq!(plan.executor, "windows_firewall"); + assert!(!plan.allow_rules.is_empty()); + assert!(!plan.block_rules.is_empty()); + assert!( + plan.apply_commands + .iter() + .any(|command| command.contains("New-NetFirewallRule")) + ); + assert!( + plan.rollback_commands + .iter() + .any(|command| command.contains("Remove-NetFirewallRule")) + ); + } + + #[test] + fn windows_firewall_plan_requires_management_allowlist() { + let mut request = sample_windows_firewall_request(); + request.management_allowlist.clear(); + let plan = build_windows_firewall_plan(&request); + + assert!( + plan.blockers + .contains(&"management_allowlist_empty".to_string()) + ); + } + + #[test] + fn windows_firewall_plan_refuses_broad_block() { + let mut request = sample_windows_firewall_request(); + request.blocked_remote_addresses = vec!["Any".to_string()]; + let plan = build_windows_firewall_plan(&request); + + assert!( + plan.blockers + .iter() + .any(|blocker| blocker == "broad_block_refused:Any") + ); + } + + #[test] + fn windows_firewall_plan_refuses_management_subnet_overlap() { + let mut request = sample_windows_firewall_request(); + request.management_allowlist = vec!["10.10.10.10".to_string()]; + request.blocked_remote_addresses = vec!["10.10.10.0/24".to_string()]; + let plan = build_windows_firewall_plan(&request); + + assert!( + plan.blockers + .contains(&"management_allowlist_overlaps_blocked_remote_addresses".to_string()) + ); + } + + #[test] + fn windows_firewall_apply_without_confirmation_is_refused() { + let request = sample_windows_firewall_request(); + let plan = build_windows_firewall_plan(&request); + let result = run_windows_firewall_executor(&plan, "apply", "NO", false).unwrap(); + + assert!(!result.ok); + assert!(!result.would_mutate); + assert_eq!(result.execution_status, "refused"); + assert!( + result + .blockers + .contains(&"confirm_apply_must_be_YES".to_string()) + ); + } + + #[test] + fn windows_firewall_apply_confirmed_without_execute_is_dry_run() { + let request = sample_windows_firewall_request(); + let plan = build_windows_firewall_plan(&request); + let result = run_windows_firewall_executor(&plan, "apply", "YES", false).unwrap(); + + assert!(result.ok); + assert!(!result.would_mutate); + assert_eq!(result.execution_status, "dry_run_commands_ready"); + assert!(!result.commands.is_empty()); + } +} diff --git a/adk-rust/crates/hayabusa-tools/Cargo.toml b/adk-rust/crates/hayabusa-tools/Cargo.toml index 3e527cc..78ed851 100644 --- a/adk-rust/crates/hayabusa-tools/Cargo.toml +++ b/adk-rust/crates/hayabusa-tools/Cargo.toml @@ -32,6 +32,8 @@ reqwest.workspace = true serde.workspace = true serde_json.workspace = true urlencoding.workspace = true +sha2.workspace = true +zip = { version = "2", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile.workspace = true diff --git a/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs b/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs index f4502bf..edc9817 100644 --- a/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs +++ b/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs @@ -1,17 +1,22 @@ use std::fs::{self, File}; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; +use chrono::Utc; use clap::Parser; use fs2::FileExt; -use hayabusa_tools::{guess_host_from_filename, read_json_file}; +use hayabusa_tools::{env_bool, env_string, guess_host_from_filename, read_json_file}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use zip::ZipArchive; const LOCK_PATH: &str = "/opt/hayabusa/state/aw-hayabusa-autoprocess.lock"; const WRAPPER: &str = "/usr/local/bin/aw-hayabusa"; const LINKER: &str = "/usr/local/bin/aw-hayabusa-link-case"; const CASE_ALERT: &str = "/usr/local/bin/aw-hayabusa-case-alert"; +const SECURITY_FINDING_INBOX: &str = "/usr/local/bin/security-finding-inbox"; const LATEST_INTAKE: &str = "/opt/hayabusa/state/latest-intake.json"; #[derive(Debug, Parser)] @@ -20,6 +25,9 @@ struct Cli { #[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/drop")] drop_dir: PathBuf, + #[arg(long, default_value = "/opt/hayabusa/quarantine/drop")] + quarantine_dir: PathBuf, + #[arg(long, default_value_t = true)] once: bool, } @@ -65,16 +73,43 @@ fn run() -> Result { println!("no zip packages in drop dir"); return Ok(0); } + let mut operational_failures = 0usize; for zip_path in zips { - let result = process_one(&zip_path)?; - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "processed": zip_path.display().to_string(), - "latest_intake": result.latest_intake, - "case_alert": result.case_alert, - }))? - ); + if let Err(err) = validate_drop_inputs(&zip_path) { + let quarantine_dir = quarantine_drop_package(&cli.quarantine_dir, &zip_path, &err)?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "quarantined": zip_path.display().to_string(), + "quarantine_dir": quarantine_dir.display().to_string(), + "reason": err.to_string(), + }))? + ); + continue; + } + match process_one(&zip_path) { + Ok(result) => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "processed": zip_path.display().to_string(), + "latest_intake": result.latest_intake, + "case_alert": result.case_alert, + "security_finding_ingest": result.security_finding_ingest, + }))? + ); + } + Err(err) => { + operational_failures += 1; + eprintln!( + "ERROR: operational failure while processing {}: {err:#}", + zip_path.display() + ); + } + } + } + if operational_failures > 0 { + bail!("{operational_failures} operational Hayabusa package failure(s)"); } Ok(0) } @@ -82,6 +117,7 @@ fn run() -> Result { struct ProcessResult { latest_intake: Value, case_alert: Option, + security_finding_ingest: Option, } fn list_zips(drop_dir: &Path) -> Result> { @@ -96,6 +132,42 @@ fn list_zips(drop_dir: &Path) -> Result> { Ok(zips) } +fn validate_drop_inputs(zip_path: &Path) -> Result<()> { + validate_zip_package(zip_path)?; + load_sidecars(zip_path)?; + Ok(()) +} + +fn validate_zip_package(zip_path: &Path) -> Result<()> { + let file = File::open(zip_path).with_context(|| format!("open {}", zip_path.display()))?; + let mut archive = + ZipArchive::new(file).with_context(|| format!("read zip {}", zip_path.display()))?; + if archive.is_empty() { + bail!("zip package has no entries: {}", zip_path.display()); + } + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .with_context(|| format!("read zip entry {index} from {}", zip_path.display()))?; + let name = entry.name().replace('\\', "/"); + if name.starts_with('/') || name.split('/').any(|part| part == "..") { + bail!( + "unsafe zip entry in {}: {}", + zip_path.display(), + entry.name() + ); + } + io::copy(&mut entry, &mut io::sink()).with_context(|| { + format!( + "test zip entry {} from {}", + entry.name(), + zip_path.display() + ) + })?; + } + Ok(()) +} + fn process_one(zip_path: &Path) -> Result { let sidecars = load_sidecars(zip_path)?; let host = guess_host(zip_path, &sidecars); @@ -123,6 +195,7 @@ fn process_one(zip_path: &Path) -> Result { ], )?; let latest = read_json_file(Path::new(LATEST_INTAKE))?; + let security_finding_ingest = ingest_security_finding_best_effort(Path::new(LATEST_INTAKE))?; let report_dir = PathBuf::from( latest .get("report_dir") @@ -154,6 +227,7 @@ fn process_one(zip_path: &Path) -> Result { return Ok(ProcessResult { latest_intake: latest, case_alert, + security_finding_ingest, }); } run_checked( @@ -171,9 +245,63 @@ fn process_one(zip_path: &Path) -> Result { Ok(ProcessResult { latest_intake: latest, case_alert, + security_finding_ingest, }) } +fn ingest_security_finding_best_effort(intake_path: &Path) -> Result> { + if !env_bool("AW_SECURITY_FINDING_INBOX_ENABLED", false) { + return Ok(None); + } + let binary = PathBuf::from(env_string( + "AW_SECURITY_FINDING_INBOX_BIN", + SECURITY_FINDING_INBOX, + )); + let required = env_bool("AW_SECURITY_FINDING_INBOX_REQUIRED", false); + if !binary.is_file() { + let message = format!( + "security finding inbox binary not found: {}", + binary.display() + ); + if required { + bail!("{message}"); + } + eprintln!("WARNING: {message}"); + return Ok(Some(json!({"ok": false, "warning": message}))); + } + let min_severity = env_string("AW_SECURITY_FINDING_INBOX_MIN_SEVERITY", "medium"); + let output = Command::new(&binary) + .arg("ingest-hayabusa") + .arg("--intake") + .arg(intake_path) + .arg("--min-severity") + .arg(min_severity) + .output() + .with_context(|| format!("run {}", binary.display()))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + let message = format!( + "security finding ingest failed: status={} stderr={}", + output.status, + stderr.trim() + ); + if required { + bail!("{message}"); + } + eprintln!("WARNING: {message}"); + return Ok(Some(json!({"ok": false, "warning": message}))); + } + let payload = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| { + json!({ + "ok": true, + "stdout": stdout.trim(), + "stderr": stderr.trim() + }) + }); + Ok(Some(payload)) +} + fn load_sidecars(zip_path: &Path) -> Result { let base = zip_path.with_extension(""); let caseid_path = base.with_extension("caseid"); @@ -241,6 +369,108 @@ fn archive_drop_package(report_dir: &Path, zip_path: &Path) -> Result<()> { Ok(()) } +fn quarantine_drop_package( + quarantine_root: &Path, + zip_path: &Path, + err: &anyhow::Error, +) -> Result { + fs::create_dir_all(quarantine_root) + .with_context(|| format!("create {}", quarantine_root.display()))?; + let name = zip_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("package.zip"); + let stamp = Utc::now().format("%Y%m%dT%H%M%SZ"); + let mut quarantine_dir = quarantine_root.join(format!("{stamp}_{}", sanitize_component(name))); + if quarantine_dir.exists() { + quarantine_dir = quarantine_root.join(format!( + "{stamp}_{}_{}", + sanitize_component(name), + std::process::id() + )); + } + fs::create_dir_all(&quarantine_dir) + .with_context(|| format!("create {}", quarantine_dir.display()))?; + + let sha256 = if zip_path.is_file() { + Some(sha256_file(zip_path)?) + } else { + None + }; + move_if_exists(zip_path, &quarantine_dir)?; + let base = zip_path.with_extension(""); + for sidecar in [ + base.with_extension("caseid"), + base.with_extension("meta.json"), + zip_path.with_extension("zip.sha256"), + ] { + move_if_exists(&sidecar, &quarantine_dir)?; + } + let reason = json!({ + "quarantined_at": Utc::now().to_rfc3339(), + "source": "aw-hayabusa-autoprocess-rust", + "original_path": zip_path.display().to_string(), + "sha256": sha256, + "reason": err.to_string(), + "detail": format!("{err:#}"), + "operator_action": "inspect source package, re-export EVTX archive if needed, then replay by moving a fixed package back to the drop directory", + }); + fs::write( + quarantine_dir.join("reason.json"), + serde_json::to_string_pretty(&reason)?, + ) + .with_context(|| format!("write {}", quarantine_dir.join("reason.json").display()))?; + Ok(quarantine_dir) +} + +fn move_if_exists(path: &Path, target_dir: &Path) -> Result<()> { + if !path.exists() { + return Ok(()); + } + let target = target_dir.join(path.file_name().context("quarantine file name")?); + fs::rename(path, &target) + .or_else(|_| { + fs::copy(path, &target)?; + fs::remove_file(path) + }) + .with_context(|| format!("move {} to {}", path.display(), target.display()))?; + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let read = file + .read(&mut buf) + .with_context(|| format!("read {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buf[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn sanitize_component(value: &str) -> String { + let clean = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + ch + } else { + '_' + } + }) + .collect::(); + if clean.is_empty() { + "package".to_string() + } else { + clean + } +} + fn guess_host(zip_path: &Path, sidecars: &Sidecars) -> Option { if let Some(host) = &sidecars.host { if !host.is_empty() { @@ -302,3 +532,57 @@ fn run_capture(program: &Path, args: &[String]) -> Result { stderr: String::from_utf8_lossy(&output.stderr).to_string(), }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn invalid_zip_is_rejected_before_accept() { + let dir = tempfile::tempdir().unwrap(); + let zip_path = dir.path().join("bad.zip"); + fs::write(&zip_path, b"not a zip").unwrap(); + + let err = validate_drop_inputs(&zip_path).unwrap_err(); + assert!(err.to_string().contains("read zip")); + } + + #[test] + fn quarantine_moves_package_sidecars_and_writes_reason() { + let dir = tempfile::tempdir().unwrap(); + let drop = dir.path().join("drop"); + let quarantine = dir.path().join("quarantine"); + fs::create_dir_all(&drop).unwrap(); + let zip_path = drop.join("HOST-20260624.zip"); + fs::write(&zip_path, b"bad").unwrap(); + fs::write(drop.join("HOST-20260624.meta.json"), b"{bad").unwrap(); + fs::write(drop.join("HOST-20260624.caseid"), b"30").unwrap(); + + let err = anyhow::anyhow!("bad zip"); + let target = quarantine_drop_package(&quarantine, &zip_path, &err).unwrap(); + + assert!(!zip_path.exists()); + assert!(target.join("HOST-20260624.zip").is_file()); + assert!(target.join("HOST-20260624.meta.json").is_file()); + assert!(target.join("HOST-20260624.caseid").is_file()); + let reason = fs::read_to_string(target.join("reason.json")).unwrap(); + assert!(reason.contains("bad zip")); + assert!(reason.contains("aw-hayabusa-autoprocess-rust")); + } + + #[test] + fn valid_zip_with_backslash_entry_is_accepted_by_precheck() { + let dir = tempfile::tempdir().unwrap(); + let zip_path = dir.path().join("ok.zip"); + let file = File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + zip.start_file("evtx\\sample.evtx", options).unwrap(); + zip.write_all(b"evtx").unwrap(); + zip.finish().unwrap(); + + validate_drop_inputs(&zip_path).unwrap(); + } +} diff --git a/adk-rust/crates/security-finding-inbox/Cargo.toml b/adk-rust/crates/security-finding-inbox/Cargo.toml new file mode 100644 index 0000000..be77a33 --- /dev/null +++ b/adk-rust/crates/security-finding-inbox/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "security-finding-inbox" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +hayabusa-tools = { path = "../hayabusa-tools" } +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/security-finding-inbox/src/main.rs b/adk-rust/crates/security-finding-inbox/src/main.rs new file mode 100644 index 0000000..3ad59d7 --- /dev/null +++ b/adk-rust/crates/security-finding-inbox/src/main.rs @@ -0,0 +1,1968 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, SecondsFormat, Utc}; +use clap::{Parser, Subcommand, ValueEnum}; +use hayabusa_tools::{analyze_report, read_json_file, required_str, severity_meets}; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const SCHEMA_SQL: &str = + include_str!("../../../../clickhouse-1c/security/security_finding_inbox.sql"); + +#[derive(Debug, Parser)] +#[command(about = "AWatch-rus Security Finding Inbox ingest and workflow CLI")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Print ClickHouse schema SQL. + Schema, + /// Print a normalized sample finding. + Sample { + #[arg(long)] + pretty: bool, + }, + /// Validate normalized finding JSON or JSONL without writing. + Validate { + #[arg(long)] + input: PathBuf, + }, + /// Ingest normalized finding JSON or JSONL into ClickHouse. + Ingest { + #[arg(long)] + input: PathBuf, + + #[arg(long, default_value = "http://127.0.0.1:8123", env = "CLICKHOUSE_URL")] + clickhouse_url: String, + + #[arg(long, default_value = "analytics_1c", env = "CLICKHOUSE_DATABASE")] + database: String, + + #[arg(long, default_value = "default", env = "CLICKHOUSE_USER")] + user: String, + + #[arg(long, default_value = "", env = "CLICKHOUSE_PASSWORD")] + password: String, + + #[arg(long, default_value_t = 10)] + timeout_seconds: u64, + + #[arg(long)] + apply_schema: bool, + + #[arg(long)] + dry_run: bool, + }, + /// Convert a real Hayabusa intake/report into normalized findings and ingest them. + IngestHayabusa { + #[arg(long, default_value = "/opt/hayabusa/state/latest-intake.json")] + intake: PathBuf, + + #[arg(long, default_value = "medium")] + min_severity: String, + + #[arg(long, default_value = "http://127.0.0.1:8123", env = "CLICKHOUSE_URL")] + clickhouse_url: String, + + #[arg(long, default_value = "analytics_1c", env = "CLICKHOUSE_DATABASE")] + database: String, + + #[arg(long, default_value = "default", env = "CLICKHOUSE_USER")] + user: String, + + #[arg(long, default_value = "", env = "CLICKHOUSE_PASSWORD")] + password: String, + + #[arg(long, default_value_t = 10)] + timeout_seconds: u64, + + #[arg(long)] + apply_schema: bool, + + #[arg(long)] + dry_run: bool, + }, + /// Convert Velociraptor JSON/JSONL artifact output into normalized findings. + IngestVelociraptorJson { + #[arg(long)] + input: PathBuf, + + #[arg(long, default_value = "high")] + default_severity: String, + + #[arg(long, default_value = "http://127.0.0.1:8123", env = "CLICKHOUSE_URL")] + clickhouse_url: String, + + #[arg(long, default_value = "analytics_1c", env = "CLICKHOUSE_DATABASE")] + database: String, + + #[arg(long, default_value = "default", env = "CLICKHOUSE_USER")] + user: String, + + #[arg(long, default_value = "", env = "CLICKHOUSE_PASSWORD")] + password: String, + + #[arg(long, default_value_t = 10)] + timeout_seconds: u64, + + #[arg(long)] + apply_schema: bool, + + #[arg(long)] + dry_run: bool, + }, + /// Record an operator workflow event for a finding. + Workflow { + #[arg(long)] + finding_id: String, + + #[arg(long, value_enum)] + event_type: WorkflowEventType, + + #[arg(long, default_value = "operator")] + actor: String, + + #[arg(long, default_value = "")] + comment: String, + + #[arg(long, default_value = "")] + decision_status: String, + + #[arg(long, default_value = "")] + rollback_plan_id: String, + + #[arg(long, default_value = "")] + plan_id: String, + + #[arg(long, default_value = "{}")] + evidence_json: String, + + #[arg(long, default_value = "http://127.0.0.1:8123", env = "CLICKHOUSE_URL")] + clickhouse_url: String, + + #[arg(long, default_value = "analytics_1c", env = "CLICKHOUSE_DATABASE")] + database: String, + + #[arg(long, default_value = "default", env = "CLICKHOUSE_USER")] + user: String, + + #[arg(long, default_value = "", env = "CLICKHOUSE_PASSWORD")] + password: String, + + #[arg(long, default_value_t = 10)] + timeout_seconds: u64, + + #[arg(long)] + dry_run: bool, + }, + /// Process approved apply_requested findings through containment plan/apply/verify/rollback. + Executor { + #[arg(long, default_value = "http://127.0.0.1:8123", env = "CLICKHOUSE_URL")] + clickhouse_url: String, + + #[arg(long, default_value = "analytics_1c", env = "CLICKHOUSE_DATABASE")] + database: String, + + #[arg(long, default_value = "default", env = "CLICKHOUSE_USER")] + user: String, + + #[arg(long, default_value = "", env = "CLICKHOUSE_PASSWORD")] + password: String, + + #[arg(long, default_value_t = 10)] + timeout_seconds: u64, + + #[arg( + long, + default_value = "/etc/activitywatch/containment-policy.json", + env = "AW_CONTAINMENT_POLICY" + )] + policy: PathBuf, + + #[arg( + long, + default_value = "containment-engine", + env = "AW_CONTAINMENT_ENGINE_BIN" + )] + containment_engine_bin: PathBuf, + + #[arg( + long, + default_value = "/var/lib/activitywatch/security-finding-executor", + env = "AW_SECURITY_FINDING_EXECUTOR_WORK_DIR" + )] + work_dir: PathBuf, + + #[arg( + long, + default_value = "/var/lock/aw-security-finding-executor.lock", + env = "AW_SECURITY_FINDING_EXECUTOR_LOCK" + )] + lock_path: PathBuf, + + #[arg( + long, + value_delimiter = ',', + env = "AW_CONTAINMENT_MANAGEMENT_ALLOWLIST" + )] + management_allowlist: Vec, + + #[arg( + long, + value_delimiter = ',', + env = "AW_CONTAINMENT_BLOCKED_REMOTE_ADDRESSES" + )] + blocked_remote_addresses: Vec, + + #[arg(long, value_delimiter = ',', default_value = "Domain")] + profiles: Vec, + + #[arg(long, default_value_t = 60)] + poll_seconds: u64, + + #[arg(long, default_value_t = 10)] + limit: usize, + + #[arg(long)] + once: bool, + + #[arg(long)] + execute_local: bool, + + #[arg(long, default_value = "NO")] + confirm_execute: String, + + #[arg(long)] + executor_host: Option, + + #[arg(long)] + dry_run: bool, + }, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +#[clap(rename_all = "snake_case")] +enum WorkflowEventType { + DecideRequested, + PlanRequested, + Approved, + ApplyRequested, + VerifyRequested, + RollbackRequested, + Rejected, + FalsePositive, +} + +impl WorkflowEventType { + fn as_str(self) -> &'static str { + match self { + Self::DecideRequested => "decide_requested", + Self::PlanRequested => "plan_requested", + Self::Approved => "approved", + Self::ApplyRequested => "apply_requested", + Self::VerifyRequested => "verify_requested", + Self::RollbackRequested => "rollback_requested", + Self::Rejected => "rejected", + Self::FalsePositive => "false_positive", + } + } + + fn status(self) -> &'static str { + match self { + Self::DecideRequested => "decision_pending", + Self::PlanRequested => "plan_pending", + Self::Approved => "approved", + Self::ApplyRequested => "apply_pending", + Self::VerifyRequested => "verify_pending", + Self::RollbackRequested => "rollback_pending", + Self::Rejected => "rejected", + Self::FalsePositive => "false_positive", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SecurityFindingInput { + ts: Option, + finding_id: Option, + host: String, + user: Option, + ip: Option, + department: Option, + state: Option, + severity: String, + confidence: Option, + score: Option, + source: String, + rule_id: String, + rule_title: Option, + summary: String, + recommended_action: Option, + management_channel_checked: Option, + evidence_ref: Option, + metadata: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SecurityFindingRow { + ts: String, + finding_id: String, + host: String, + user: String, + ip: String, + department: String, + state: String, + severity: String, + confidence: String, + score: u16, + source: String, + rule_id: String, + rule_title: String, + summary: String, + recommended_action: String, + management_channel_checked: u8, + evidence_ref: String, + raw_json: String, +} + +#[derive(Debug, Clone, Serialize)] +struct WorkflowRow { + ts: String, + finding_id: String, + event_type: String, + status: String, + actor: String, + comment: String, + decision_status: String, + rollback_plan_id: String, + plan_id: String, + evidence_json: String, +} + +#[derive(Debug, Clone, Serialize)] +struct IngestSummary { + ok: bool, + rows: usize, + dry_run: bool, + applied_schema: bool, + finding_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct ExecutorCandidate { + finding_id: String, + host: String, + state: String, + severity: String, + confidence: String, + source: String, + rule_id: String, + rule_title: String, + summary: String, + recommended_action: String, + management_channel_checked: u8, + evidence_ref: String, + raw_json: String, +} + +#[derive(Debug, Clone, Serialize)] +struct ExecutorSummary { + ok: bool, + dry_run: bool, + execute_local: bool, + candidates: usize, + processed: usize, + refused: usize, + applied: usize, + failed: usize, +} + +#[derive(Debug)] +struct ExecutorConfig { + client: ClickHouseClient, + policy: PathBuf, + containment_engine_bin: PathBuf, + work_dir: PathBuf, + management_allowlist: Vec, + blocked_remote_addresses: Vec, + profiles: Vec, + limit: usize, + execute_local: bool, + confirm_execute: String, + executor_host: String, + dry_run: bool, +} + +#[derive(Debug, Serialize)] +struct ContainmentFinding { + host: String, + host_role: String, + state: String, + confidence: String, + signals: Vec, + recommended_action: String, + management_channel_checked: bool, + manual_operator_flag: bool, +} + +#[derive(Debug, Serialize)] +struct ContainmentSignal { + source: String, + rule_id: String, + confidence: String, +} + +#[derive(Debug, Serialize)] +struct WindowsFirewallRequest { + target_host: String, + plan_id: String, + ttl_minutes: u32, + reason: String, + management_allowlist: Vec, + blocked_remote_addresses: Vec, + profiles: Vec, +} + +struct IngestTarget { + clickhouse_url: String, + database: String, + user: String, + password: String, + timeout_seconds: u64, + apply_schema: bool, + dry_run: bool, +} + +struct ExecutorEvent<'a> { + finding_id: &'a str, + event_type: &'a str, + status: &'a str, + comment: &'a str, + decision_status: &'a str, + plan_id: &'a str, + evidence: Value, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Schema => { + print!("{SCHEMA_SQL}"); + Ok(()) + } + Command::Sample { pretty } => print_json(&sample_finding(), pretty), + Command::Validate { input } => { + let rows = read_finding_rows(&input)?; + print_json( + &json!({ + "ok": true, + "rows": rows.len(), + "finding_ids": rows.iter().map(|row| row.finding_id.clone()).collect::>() + }), + true, + ) + } + Command::Ingest { + input, + clickhouse_url, + database, + user, + password, + timeout_seconds, + apply_schema, + dry_run, + } => { + let rows = read_finding_rows(&input)?; + let client = ClickHouseClient::new( + clickhouse_url, + database, + user, + password, + Duration::from_secs(timeout_seconds), + )?; + if apply_schema && !dry_run { + client.apply_schema()?; + } + if !dry_run { + client.insert_json_each_row("security_findings", &rows)?; + } + print_json( + &IngestSummary { + ok: true, + rows: rows.len(), + dry_run, + applied_schema: apply_schema && !dry_run, + finding_ids: rows.iter().map(|row| row.finding_id.clone()).collect(), + }, + true, + ) + } + Command::IngestHayabusa { + intake, + min_severity, + clickhouse_url, + database, + user, + password, + timeout_seconds, + apply_schema, + dry_run, + } => { + let rows = hayabusa_intake_rows(&intake, &min_severity)?; + ingest_rows( + rows, + IngestTarget { + clickhouse_url, + database, + user, + password, + timeout_seconds, + apply_schema, + dry_run, + }, + ) + } + Command::IngestVelociraptorJson { + input, + default_severity, + clickhouse_url, + database, + user, + password, + timeout_seconds, + apply_schema, + dry_run, + } => { + let rows = velociraptor_rows(&input, &default_severity)?; + ingest_rows( + rows, + IngestTarget { + clickhouse_url, + database, + user, + password, + timeout_seconds, + apply_schema, + dry_run, + }, + ) + } + Command::Workflow { + finding_id, + event_type, + actor, + comment, + decision_status, + rollback_plan_id, + plan_id, + evidence_json, + clickhouse_url, + database, + user, + password, + timeout_seconds, + dry_run, + } => { + let row = workflow_row(WorkflowBuildInput { + finding_id: &finding_id, + event_type, + actor: &actor, + comment: &comment, + decision_status: &decision_status, + rollback_plan_id: &rollback_plan_id, + plan_id: &plan_id, + evidence_json: &evidence_json, + })?; + if !dry_run { + let client = ClickHouseClient::new( + clickhouse_url, + database, + user, + password, + Duration::from_secs(timeout_seconds), + )?; + client.insert_json_each_row( + "security_finding_workflow_events", + std::slice::from_ref(&row), + )?; + } + print_json( + &json!({ + "ok": true, + "dry_run": dry_run, + "finding_id": row.finding_id, + "event_type": row.event_type, + "status": row.status + }), + true, + ) + } + Command::Executor { + clickhouse_url, + database, + user, + password, + timeout_seconds, + policy, + containment_engine_bin, + work_dir, + lock_path, + management_allowlist, + blocked_remote_addresses, + profiles, + poll_seconds, + limit, + once, + execute_local, + confirm_execute, + executor_host, + dry_run, + } => { + let _lock = acquire_lock(&lock_path)?; + fs::create_dir_all(&work_dir) + .with_context(|| format!("create executor work_dir {}", work_dir.display()))?; + let client = ClickHouseClient::new( + clickhouse_url, + database, + user, + password, + Duration::from_secs(timeout_seconds), + )?; + let config = ExecutorConfig { + client, + policy, + containment_engine_bin, + work_dir, + management_allowlist: clean_string_list(management_allowlist), + blocked_remote_addresses: clean_string_list(blocked_remote_addresses), + profiles: clean_string_list(profiles), + limit, + execute_local, + confirm_execute, + executor_host: executor_host.unwrap_or_else(local_executor_host), + dry_run, + }; + loop { + let summary = run_executor_once(&config)?; + print_json(&summary, true)?; + if once { + break Ok(()); + } + thread::sleep(Duration::from_secs(poll_seconds.max(5))); + } + } + } +} + +fn ingest_rows(rows: Vec, target: IngestTarget) -> Result<()> { + let client = ClickHouseClient::new( + target.clickhouse_url, + target.database, + target.user, + target.password, + Duration::from_secs(target.timeout_seconds), + )?; + if target.apply_schema && !target.dry_run { + client.apply_schema()?; + } + if !target.dry_run { + client.insert_json_each_row("security_findings", &rows)?; + } + print_json( + &IngestSummary { + ok: true, + rows: rows.len(), + dry_run: target.dry_run, + applied_schema: target.apply_schema && !target.dry_run, + finding_ids: rows.iter().map(|row| row.finding_id.clone()).collect(), + }, + true, + ) +} + +fn hayabusa_intake_rows(intake_path: &Path, min_severity: &str) -> Result> { + let intake = read_json_file(intake_path)?; + let host = required_str(&intake, "host")?; + let report_dir = PathBuf::from(required_str(&intake, "report_dir")?); + let intake_id = required_str(&intake, "intake_id").unwrap_or("unknown-intake"); + let summary = analyze_report(&report_dir) + .with_context(|| format!("analyze Hayabusa report {}", report_dir.display()))?; + let severity = normalize_enum( + &summary.severity, + "severity", + &["low", "medium", "high", "critical"], + )?; + let min_severity = normalize_enum( + min_severity, + "min_severity", + &["low", "medium", "high", "critical"], + )?; + if !severity_meets(&severity, &min_severity) { + return Ok(Vec::new()); + } + let top_rule = summary + .top_rules + .first() + .map(|rule| rule.title.as_str()) + .unwrap_or("hayabusa-no-dominant-rule"); + let first_ts = summary + .first_timestamp + .as_deref() + .or(summary.last_timestamp.as_deref()); + let finding = SecurityFindingInput { + ts: first_ts.map(str::to_string), + finding_id: Some(generated_finding_id( + first_ts.unwrap_or(intake_id), + host, + "hayabusa", + top_rule, + &format!("events={}", summary.events_total), + )), + host: host.to_string(), + user: None, + ip: None, + department: None, + state: Some("suspected_infected".to_string()), + severity: severity.clone(), + confidence: Some(severity.clone()), + score: Some(summary.score.clamp(0, 100) as u16), + source: "hayabusa".to_string(), + rule_id: safe_rule_id(top_rule), + rule_title: Some(top_rule.to_string()), + summary: format!( + "Hayabusa {} finding on {}: events={}, failed_logons={}, suspicious_pwsh={}, credential_events={}", + severity, + host, + summary.events_total, + summary.failed_logon_rows, + summary.suspicious_pwsh, + summary.credential_events + ), + recommended_action: Some("windows_firewall_quarantine".to_string()), + management_channel_checked: Some(false), + evidence_ref: Some(format!("file://{}", report_dir.display())), + metadata: Some(json!({ + "source": "hayabusa", + "intake_path": intake_path.display().to_string(), + "intake_id": intake_id, + "report_dir": report_dir.display().to_string(), + "summary": summary, + })), + }; + Ok(vec![normalize_finding(&finding)?]) +} + +fn velociraptor_rows(input: &PathBuf, default_severity: &str) -> Result> { + let text = if input.as_os_str() == "-" { + let mut text = String::new(); + io::stdin().read_to_string(&mut text)?; + text + } else { + fs::read_to_string(input).with_context(|| format!("read {}", input.display()))? + }; + let severity = normalize_enum( + default_severity, + "default_severity", + &["low", "medium", "high", "critical"], + )?; + let values = parse_json_values(&text)?; + values + .iter() + .enumerate() + .map(|(index, value)| velociraptor_value_to_row(value, &severity, index)) + .collect() +} + +fn parse_json_values(text: &str) -> Result> { + let trimmed = text.trim(); + if trimmed.is_empty() { + bail!("empty source JSON"); + } + if trimmed.starts_with('[') { + return serde_json::from_str(trimmed).context("parse JSON array"); + } + if trimmed.starts_with('{') { + if let Ok(value) = serde_json::from_str::(trimmed) { + return Ok(vec![value]); + } + } + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + serde_json::from_str::(line.trim()) + .with_context(|| format!("parse JSONL line {}", index + 1)) + }) + .collect() +} + +fn velociraptor_value_to_row( + value: &Value, + default_severity: &str, + index: usize, +) -> Result { + let host = first_string( + value, + &[ + "host", + "hostname", + "Hostname", + "client_hostname", + "ClientHostname", + "Computer", + "ComputerName", + ], + ) + .unwrap_or_else(|| { + first_string(value, &["ClientId", "client_id"]) + .unwrap_or_else(|| "unknown-host".to_string()) + }); + let artifact = first_string(value, &["artifact", "Artifact", "source", "Source"]) + .unwrap_or_else(|| "velociraptor-artifact".to_string()); + let message = first_string( + value, + &[ + "summary", + "message", + "Message", + "description", + "Description", + ], + ) + .unwrap_or_else(|| format!("Velociraptor artifact result from {artifact}")); + let severity = first_string(value, &["severity", "Severity"]) + .and_then(|raw| { + normalize_enum(&raw, "severity", &["low", "medium", "high", "critical"]).ok() + }) + .unwrap_or_else(|| default_severity.to_string()); + let ts = first_string(value, &["ts", "timestamp", "Timestamp", "_ts"]); + let finding = SecurityFindingInput { + ts, + finding_id: first_string(value, &["finding_id", "FindingId"]), + host, + user: first_string(value, &["user", "User", "Username"]), + ip: first_string(value, &["ip", "Ip", "IP", "RemoteIP"]), + department: None, + state: Some("suspected_infected".to_string()), + severity: severity.clone(), + confidence: Some(severity), + score: first_number(value, &["score", "Score"]).map(|score| score.min(100) as u16), + source: "velociraptor".to_string(), + rule_id: safe_rule_id(&artifact), + rule_title: Some(artifact), + summary: message, + recommended_action: Some("windows_firewall_quarantine".to_string()), + management_channel_checked: Some(false), + evidence_ref: Some(format!("velociraptor://record/{index}")), + metadata: Some(value.clone()), + }; + normalize_finding(&finding) +} + +fn run_executor_once(config: &ExecutorConfig) -> Result { + let candidates = query_executor_candidates(config)?; + let mut summary = ExecutorSummary { + ok: true, + dry_run: config.dry_run, + execute_local: config.execute_local, + candidates: candidates.len(), + processed: 0, + refused: 0, + applied: 0, + failed: 0, + }; + for candidate in candidates { + match process_executor_candidate(config, &candidate) { + Ok("applied") => { + summary.processed += 1; + summary.applied += 1; + } + Ok("refused") => { + summary.processed += 1; + summary.refused += 1; + summary.ok = false; + } + Ok(_) => { + summary.processed += 1; + } + Err(err) => { + summary.processed += 1; + summary.failed += 1; + summary.ok = false; + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_failed", + status: "executor_failed", + comment: &format!("{err:#}"), + decision_status: "", + plan_id: "", + evidence: json!({"error": err.to_string()}), + }, + )?; + } + } + } + Ok(summary) +} + +fn query_executor_candidates(config: &ExecutorConfig) -> Result> { + let limit = config.limit.clamp(1, 100); + let sql = format!( + r#" +SELECT + finding_id, + host, + state, + severity, + confidence, + source, + rule_id, + rule_title, + summary, + recommended_action, + toUInt8(management_channel_checked) AS management_channel_checked, + evidence_ref, + raw_json +FROM {database}.security_finding_inbox +WHERE last_workflow_event = 'apply_requested' + AND workflow_status = 'apply_pending' + AND finding_id IN ( + SELECT finding_id + FROM {database}.security_finding_workflow_events + WHERE event_type = 'approved' + ) + AND finding_id NOT IN ( + SELECT finding_id + FROM {database}.security_finding_workflow_events + WHERE event_type IN ( + 'executor_apply_succeeded', + 'executor_apply_failed', + 'executor_refused', + 'executor_rollback_succeeded', + 'executor_rollback_failed' + ) + ) +ORDER BY + multiIf(severity = 'critical', 4, severity = 'high', 3, severity = 'medium', 2, 1) DESC, + last_seen DESC +LIMIT {limit} +FORMAT JSONEachRow +"#, + database = config.client.database + ); + config + .client + .query_json_each_row(&sql)? + .into_iter() + .map(|value| serde_json::from_value(value).context("decode executor candidate")) + .collect() +} + +fn process_executor_candidate( + config: &ExecutorConfig, + candidate: &ExecutorCandidate, +) -> Result<&'static str> { + let mut blockers = executor_candidate_blockers(config, candidate); + if !blockers.is_empty() { + blockers.sort(); + blockers.dedup(); + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_refused", + status: "executor_refused", + comment: &blockers.join(";"), + decision_status: "", + plan_id: "", + evidence: json!({"blockers": blockers, "candidate": candidate_context(candidate), "dry_run": config.dry_run}), + }, + )?; + return Ok("refused"); + } + + let finding_path = config + .work_dir + .join(format!("{}-finding.json", candidate.finding_id)); + let request_path = config.work_dir.join(format!( + "{}-windows-firewall-request.json", + candidate.finding_id + )); + let plan_path = config.work_dir.join(format!( + "{}-windows-firewall-plan.json", + candidate.finding_id + )); + let containment_finding = containment_finding_from_candidate(candidate); + write_json_file(&finding_path, &containment_finding)?; + + let decision_args = vec![ + "decide".to_string(), + "--policy".to_string(), + path_arg(&config.policy), + "--finding".to_string(), + path_arg(&finding_path), + ]; + let decision = run_json_command_owned(&config.containment_engine_bin, &decision_args)?; + let decision_status = decision + .get("decision_status") + .and_then(Value::as_str) + .unwrap_or(""); + let decision_ok = decision.get("ok").and_then(Value::as_bool).unwrap_or(false); + if !decision_ok || !matches!(decision_status, "manual_approval_required" | "auto_ready") { + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_refused", + status: "executor_refused", + comment: &format!("containment decision refused: {decision_status}"), + decision_status, + plan_id: "", + evidence: json!({"decision": decision, "candidate": candidate_context(candidate), "dry_run": config.dry_run}), + }, + )?; + return Ok("refused"); + } + + let plan_id = candidate_plan_id(candidate); + let request = WindowsFirewallRequest { + target_host: candidate.host.clone(), + plan_id: plan_id.clone(), + ttl_minutes: 60, + reason: format!( + "AWatch-rus approved containment for {}", + candidate.finding_id + ), + management_allowlist: config.management_allowlist.clone(), + blocked_remote_addresses: config.blocked_remote_addresses.clone(), + profiles: config.profiles.clone(), + }; + write_json_file(&request_path, &request)?; + let plan_args = vec![ + "windows-firewall".to_string(), + "plan".to_string(), + "--request".to_string(), + path_arg(&request_path), + ]; + let plan = run_json_command_owned(&config.containment_engine_bin, &plan_args)?; + write_json_file(&plan_path, &plan)?; + if plan + .get("blockers") + .and_then(Value::as_array) + .is_some_and(|items| !items.is_empty()) + { + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_refused", + status: "executor_refused", + comment: "windows firewall plan has blockers", + decision_status, + plan_id: &plan_id, + evidence: json!({"decision": decision, "plan": plan, "candidate": candidate_context(candidate), "dry_run": config.dry_run}), + }, + )?; + return Ok("refused"); + } + + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_plan_ready", + status: "plan_ready", + comment: "windows firewall plan generated", + decision_status, + plan_id: &plan_id, + evidence: json!({"decision": decision, "plan_path": plan_path.display().to_string(), "candidate": candidate_context(candidate), "dry_run": config.dry_run}), + }, + )?; + + let mut apply_args = vec![ + "windows-firewall".to_string(), + "apply".to_string(), + "--plan".to_string(), + path_arg(&plan_path), + "--confirm-apply".to_string(), + if config.execute_local && !config.dry_run { + config.confirm_execute.clone() + } else { + "YES".to_string() + }, + ]; + if config.execute_local && !config.dry_run { + apply_args.push("--execute-local".to_string()); + } + let apply_result = run_json_command_owned(&config.containment_engine_bin, &apply_args)?; + let apply_ok = apply_result + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(false); + if !apply_ok { + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_apply_failed", + status: "apply_failed", + comment: "windows firewall apply failed or was refused", + decision_status, + plan_id: &plan_id, + evidence: json!({"apply": apply_result, "dry_run": config.dry_run}), + }, + )?; + if config.execute_local && !config.dry_run { + run_executor_rollback(config, candidate, &plan_path, decision_status, &plan_id)?; + } + return Ok("failed"); + } + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: "executor_apply_succeeded", + status: if config.dry_run { + "apply_dry_run_ready" + } else { + "contained" + }, + comment: "windows firewall apply completed", + decision_status, + plan_id: &plan_id, + evidence: json!({"apply": apply_result, "dry_run": config.dry_run}), + }, + )?; + + let mut verify_args = vec![ + "windows-firewall".to_string(), + "verify".to_string(), + "--plan".to_string(), + path_arg(&plan_path), + ]; + if config.execute_local && !config.dry_run { + verify_args.push("--execute-local".to_string()); + } + let verify_result = run_json_command_owned(&config.containment_engine_bin, &verify_args)?; + let verify_ok = verify_result + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(false); + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: if verify_ok { + "executor_verify_succeeded" + } else { + "executor_verify_failed" + }, + status: if verify_ok { + "verify_succeeded" + } else { + "verify_failed" + }, + comment: "windows firewall verify completed", + decision_status, + plan_id: &plan_id, + evidence: json!({"verify": verify_result, "dry_run": config.dry_run}), + }, + )?; + Ok("applied") +} + +fn run_executor_rollback( + config: &ExecutorConfig, + candidate: &ExecutorCandidate, + plan_path: &Path, + decision_status: &str, + plan_id: &str, +) -> Result<()> { + let args = vec![ + "windows-firewall".to_string(), + "rollback".to_string(), + "--plan".to_string(), + path_arg(plan_path), + "--confirm-rollback".to_string(), + config.confirm_execute.clone(), + "--execute-local".to_string(), + ]; + let rollback_result = run_json_command_owned(&config.containment_engine_bin, &args)?; + let rollback_ok = rollback_result + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(false); + record_executor_event( + &config.client, + ExecutorEvent { + finding_id: &candidate.finding_id, + event_type: if rollback_ok { + "executor_rollback_succeeded" + } else { + "executor_rollback_failed" + }, + status: if rollback_ok { + "rollback_succeeded" + } else { + "rollback_failed" + }, + comment: "rollback attempted after apply failure", + decision_status, + plan_id, + evidence: json!({"rollback": rollback_result}), + }, + ) +} + +fn executor_candidate_blockers( + config: &ExecutorConfig, + candidate: &ExecutorCandidate, +) -> Vec { + let mut blockers = Vec::new(); + if candidate.recommended_action != "windows_firewall_quarantine" { + blockers.push(format!( + "unsupported_recommended_action:{}", + candidate.recommended_action + )); + } + if !matches!( + candidate.state.as_str(), + "suspected_infected" | "confirmed_infected" + ) { + blockers.push(format!("finding_state_not_actionable:{}", candidate.state)); + } + if candidate.management_channel_checked == 0 { + blockers.push("management_channel_not_checked".to_string()); + } + if config.management_allowlist.is_empty() { + blockers.push("management_allowlist_empty".to_string()); + } + if config.blocked_remote_addresses.is_empty() { + blockers.push("blocked_remote_addresses_empty".to_string()); + } + if config.execute_local && !config.dry_run { + if config.confirm_execute != "YES" { + blockers.push("confirm_execute_must_be_YES".to_string()); + } + if !same_host(&candidate.host, &config.executor_host) { + blockers.push(format!( + "executor_host_mismatch:finding_host={} executor_host={}", + candidate.host, config.executor_host + )); + } + } + blockers +} + +fn containment_finding_from_candidate(candidate: &ExecutorCandidate) -> ContainmentFinding { + ContainmentFinding { + host: candidate.host.clone(), + host_role: "workstation".to_string(), + state: candidate.state.clone(), + confidence: candidate.confidence.clone(), + signals: vec![ContainmentSignal { + source: candidate.source.clone(), + rule_id: candidate.rule_id.clone(), + confidence: candidate.confidence.clone(), + }], + recommended_action: candidate.recommended_action.clone(), + management_channel_checked: candidate.management_channel_checked > 0, + manual_operator_flag: true, + } +} + +fn candidate_context(candidate: &ExecutorCandidate) -> Value { + json!({ + "finding_id": candidate.finding_id, + "host": candidate.host, + "state": candidate.state, + "severity": candidate.severity, + "confidence": candidate.confidence, + "source": candidate.source, + "rule_id": candidate.rule_id, + "rule_title": candidate.rule_title, + "summary": candidate.summary, + "recommended_action": candidate.recommended_action, + "management_channel_checked": candidate.management_channel_checked > 0, + "evidence_ref": candidate.evidence_ref, + "raw_json": candidate.raw_json, + }) +} + +fn record_executor_event(client: &ClickHouseClient, event: ExecutorEvent<'_>) -> Result<()> { + let row = WorkflowRow { + ts: clickhouse_ts(Utc::now()), + finding_id: sanitize_required(event.finding_id, "finding_id", 128)?, + event_type: sanitize_required(event.event_type, "event_type", 128)?, + status: sanitize_required(event.status, "status", 128)?, + actor: "security-finding-executor".to_string(), + comment: sanitize_optional(Some(event.comment), 512), + decision_status: sanitize_optional(Some(event.decision_status), 128), + rollback_plan_id: String::new(), + plan_id: sanitize_optional(Some(event.plan_id), 128), + evidence_json: serde_json::to_string(&event.evidence)?, + }; + client.insert_json_each_row("security_finding_workflow_events", &[row]) +} + +fn run_json_command_owned(binary: &Path, args: &[String]) -> Result { + let output = ProcessCommand::new(binary) + .args(args) + .output() + .with_context(|| format!("execute {} {}", binary.display(), args.join(" ")))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + bail!( + "{} {} failed: status={} stderr={}", + binary.display(), + args.join(" "), + output.status, + stderr.trim() + ); + } + serde_json::from_str(stdout.trim()).with_context(|| { + format!( + "{} {} did not return JSON; stdout={} stderr={}", + binary.display(), + args.join(" "), + stdout.trim(), + stderr.trim() + ) + }) +} + +fn write_json_file(path: &Path, value: &T) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let temp_path = path.with_extension("tmp"); + { + let mut file = + File::create(&temp_path).with_context(|| format!("create {}", temp_path.display()))?; + serde_json::to_writer_pretty(&mut file, value)?; + file.write_all(b"\n")?; + file.sync_all()?; + } + fs::rename(&temp_path, path) + .with_context(|| format!("rename {} -> {}", temp_path.display(), path.display())) +} + +fn acquire_lock(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("acquire lock {}", path.display()))?; + writeln!(&file, "pid={}", std::process::id()).ok(); + Ok(LockGuard { + path: path.to_path_buf(), + _file: file, + }) +} + +struct LockGuard { + path: PathBuf, + _file: File, +} + +impl Drop for LockGuard { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn clean_string_list(values: Vec) -> Vec { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn local_executor_host() -> String { + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .unwrap_or_else(|_| "unknown-host".to_string()) +} + +fn same_host(left: &str, right: &str) -> bool { + left.trim().eq_ignore_ascii_case(right.trim()) +} + +fn candidate_plan_id(candidate: &ExecutorCandidate) -> String { + let mut hasher = Sha256::new(); + hasher.update(candidate.finding_id.as_bytes()); + hasher.update(candidate.host.as_bytes()); + format!("awfw-{:x}", hasher.finalize())[..24].to_string() +} + +fn path_arg(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +fn safe_rule_id(value: &str) -> String { + let mut output = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + while output.contains("--") { + output = output.replace("--", "-"); + } + output.trim_matches('-').chars().take(128).collect() +} + +fn first_string(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn first_number(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_u64)) +} + +fn read_finding_rows(path: &PathBuf) -> Result> { + let text = if path.as_os_str() == "-" { + let mut text = String::new(); + io::stdin().read_to_string(&mut text)?; + text + } else { + fs::read_to_string(path).with_context(|| format!("read {}", path.display()))? + }; + let findings = parse_findings(&text)?; + findings.iter().map(normalize_finding).collect() +} + +fn parse_findings(text: &str) -> Result> { + let trimmed = text.trim(); + if trimmed.is_empty() { + bail!("empty finding input"); + } + if trimmed.starts_with('[') { + return serde_json::from_str(trimmed).context("parse finding JSON array"); + } + if trimmed.starts_with('{') { + if let Ok(item) = serde_json::from_str(trimmed) { + return Ok(vec![item]); + } + } + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + serde_json::from_str(line.trim()) + .with_context(|| format!("parse finding JSONL line {}", index + 1)) + }) + .collect() +} + +fn normalize_finding(input: &SecurityFindingInput) -> Result { + let ts = normalize_ts(input.ts.as_deref())?; + let host = sanitize_required(&input.host, "host", 128)?; + let source = sanitize_required(&input.source, "source", 64)?; + let rule_id = sanitize_required(&input.rule_id, "rule_id", 128)?; + let severity = normalize_enum( + &input.severity, + "severity", + &["low", "medium", "high", "critical"], + )?; + let confidence = normalize_enum( + input.confidence.as_deref().unwrap_or(&severity), + "confidence", + &["low", "medium", "high", "critical"], + )?; + let state = normalize_enum( + input.state.as_deref().unwrap_or("suspected_infected"), + "state", + &[ + "new", + "suspected_infected", + "confirmed_infected", + "contained", + "released", + "false_positive", + ], + )?; + let recommended_action = normalize_enum( + input + .recommended_action + .as_deref() + .unwrap_or("windows_firewall_quarantine"), + "recommended_action", + &[ + "windows_firewall_quarantine", + "pfsense_host_block", + "switch_vlan_quarantine", + "disable_workstation_account", + "manual_review", + ], + )?; + let score = input + .score + .unwrap_or_else(|| severity_default_score(&severity)); + if score > 100 { + bail!("score must be <= 100"); + } + let raw_json = serde_json::to_string(input)?; + Ok(SecurityFindingRow { + finding_id: input + .finding_id + .clone() + .unwrap_or_else(|| generated_finding_id(&ts, &host, &source, &rule_id, &input.summary)), + ts, + host, + user: sanitize_optional(input.user.as_deref(), 128), + ip: sanitize_optional(input.ip.as_deref(), 64), + department: sanitize_optional(input.department.as_deref(), 128), + state, + severity, + confidence, + score, + source, + rule_id, + rule_title: sanitize_optional(input.rule_title.as_deref(), 240), + summary: sanitize_required(&input.summary, "summary", 512)?, + recommended_action, + management_channel_checked: u8::from(input.management_channel_checked.unwrap_or(false)), + evidence_ref: sanitize_optional(input.evidence_ref.as_deref(), 512), + raw_json, + }) +} + +struct WorkflowBuildInput<'a> { + finding_id: &'a str, + event_type: WorkflowEventType, + actor: &'a str, + comment: &'a str, + decision_status: &'a str, + rollback_plan_id: &'a str, + plan_id: &'a str, + evidence_json: &'a str, +} + +fn workflow_row(input: WorkflowBuildInput<'_>) -> Result { + let evidence: Value = + serde_json::from_str(input.evidence_json).context("evidence_json must be valid JSON")?; + Ok(WorkflowRow { + ts: clickhouse_ts(Utc::now()), + finding_id: sanitize_required(input.finding_id, "finding_id", 128)?, + event_type: input.event_type.as_str().to_string(), + status: input.event_type.status().to_string(), + actor: sanitize_required(input.actor, "actor", 128)?, + comment: sanitize_optional(Some(input.comment), 512), + decision_status: sanitize_optional(Some(input.decision_status), 128), + rollback_plan_id: sanitize_optional(Some(input.rollback_plan_id), 128), + plan_id: sanitize_optional(Some(input.plan_id), 128), + evidence_json: serde_json::to_string(&evidence)?, + }) +} + +#[derive(Debug, Clone)] +struct ClickHouseClient { + http: Client, + url: String, + database: String, + user: String, + password: String, +} + +impl ClickHouseClient { + fn new( + url: String, + database: String, + user: String, + password: String, + timeout: Duration, + ) -> Result { + let database = clickhouse_identifier(&database) + .ok_or_else(|| anyhow!("invalid ClickHouse database identifier"))?; + Ok(Self { + http: Client::builder() + .timeout(timeout) + .no_proxy() + .build() + .context("ClickHouse HTTP client")?, + url: url.trim_end_matches('/').to_string(), + database, + user, + password, + }) + } + + fn apply_schema(&self) -> Result<()> { + for statement in split_sql_statements(SCHEMA_SQL) { + self.execute(&statement)?; + } + Ok(()) + } + + fn insert_json_each_row(&self, table: &str, rows: &[T]) -> Result<()> { + let table = clickhouse_identifier(table) + .ok_or_else(|| anyhow!("invalid ClickHouse table identifier"))?; + let mut body = format!( + "INSERT INTO {}.{} FORMAT JSONEachRow\n", + self.database, table + ); + for row in rows { + body.push_str(&serde_json::to_string(row)?); + body.push('\n'); + } + self.execute(&body) + } + + fn query_json_each_row(&self, sql: &str) -> Result> { + let body = self.request(sql)?.trim().to_string(); + if body.is_empty() { + return Ok(Vec::new()); + } + body.lines() + .enumerate() + .map(|(index, line)| { + serde_json::from_str::(line.trim()) + .with_context(|| format!("decode ClickHouse JSONEachRow line {}", index + 1)) + }) + .collect() + } + + fn execute(&self, sql: &str) -> Result<()> { + self.request(sql).map(|_| ()) + } + + fn request(&self, sql: &str) -> Result { + let mut request = self + .http + .post(&self.url) + .query(&[("database", self.database.as_str())]) + .body(sql.to_string()); + if !self.user.trim().is_empty() { + request = request.basic_auth(self.user.trim().to_string(), Some(self.password.clone())); + } + let response = request.send().context("ClickHouse request")?; + let status = response.status(); + let body = response.text().unwrap_or_default(); + if !status.is_success() { + bail!("ClickHouse HTTP {status}: {}", body.trim()); + } + Ok(body) + } +} + +fn split_sql_statements(sql: &str) -> Vec { + sql.lines() + .filter(|line| !line.trim_start().starts_with("--")) + .collect::>() + .join("\n") + .split(';') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| format!("{item};")) + .collect() +} + +fn normalize_ts(value: Option<&str>) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => { + let parsed = DateTime::parse_from_rfc3339(value) + .with_context(|| format!("invalid RFC3339 ts: {value}"))?; + Ok(clickhouse_ts(parsed.with_timezone(&Utc))) + } + None => Ok(clickhouse_ts(Utc::now())), + } +} + +fn clickhouse_ts(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn normalize_enum(value: &str, name: &str, allowed: &[&str]) -> Result { + let normalized = value.trim().to_ascii_lowercase(); + if !allowed.contains(&normalized.as_str()) { + bail!("{name} is unsupported: {value}"); + } + Ok(normalized) +} + +fn severity_default_score(severity: &str) -> u16 { + match severity { + "critical" => 95, + "high" => 80, + "medium" => 50, + _ => 20, + } +} + +fn sanitize_required(value: &str, name: &str, max_len: usize) -> Result { + let value = sanitize_optional(Some(value), max_len); + if value.is_empty() { + bail!("{name} is required"); + } + Ok(value) +} + +fn sanitize_optional(value: Option<&str>, max_len: usize) -> String { + value + .unwrap_or("") + .chars() + .filter(|ch| !ch.is_control()) + .take(max_len) + .collect::() + .trim() + .to_string() +} + +fn generated_finding_id( + ts: &str, + host: &str, + source: &str, + rule_id: &str, + summary: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(ts.as_bytes()); + hasher.update(host.as_bytes()); + hasher.update(source.as_bytes()); + hasher.update(rule_id.as_bytes()); + hasher.update(summary.as_bytes()); + format!("sf-{:x}", hasher.finalize())[..19].to_string() +} + +fn clickhouse_identifier(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() + || !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + return None; + } + Some(trimmed.to_string()) +} + +fn sample_finding() -> SecurityFindingInput { + SecurityFindingInput { + ts: Some("2026-06-25T10:00:00Z".to_string()), + finding_id: None, + host: "HOST-EXAMPLE".to_string(), + user: Some("user-example".to_string()), + ip: Some("10.10.20.42".to_string()), + department: Some("demo".to_string()), + state: Some("suspected_infected".to_string()), + severity: "critical".to_string(), + confidence: Some("high".to_string()), + score: Some(95), + source: "hayabusa".to_string(), + rule_id: "demo-sigma-critical".to_string(), + rule_title: Some("Demo high-confidence suspicious workstation".to_string()), + summary: "Demo finding for Security Finding Inbox validation.".to_string(), + recommended_action: Some("windows_firewall_quarantine".to_string()), + management_channel_checked: Some(true), + evidence_ref: Some("demo://hayabusa/HOST-EXAMPLE/demo-sigma-critical".to_string()), + metadata: Some(json!({"sample": "true"})), + } +} + +fn print_json(value: &T, pretty: bool) -> Result<()> { + if pretty { + println!("{}", serde_json::to_string_pretty(value)?); + } else { + println!("{}", serde_json::to_string(value)?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn sample_finding_normalizes() { + let row = normalize_finding(&sample_finding()).unwrap(); + assert_eq!(row.host, "HOST-EXAMPLE"); + assert_eq!(row.severity, "critical"); + assert_eq!(row.score, 95); + assert!(row.finding_id.starts_with("sf-")); + } + + #[test] + fn invalid_severity_is_rejected() { + let mut finding = sample_finding(); + finding.severity = "panic".to_string(); + assert!(normalize_finding(&finding).is_err()); + } + + #[test] + fn parse_jsonl() { + let line = serde_json::to_string(&sample_finding()).unwrap(); + let input = format!("{line}\n{line}\n"); + assert_eq!(parse_findings(&input).unwrap().len(), 2); + } + + #[test] + fn workflow_event_status_is_mapped() { + let row = workflow_row(WorkflowBuildInput { + finding_id: "sf-demo", + event_type: WorkflowEventType::Approved, + actor: "operator", + comment: "ok", + decision_status: "", + rollback_plan_id: "", + plan_id: "", + evidence_json: "{}", + }) + .unwrap(); + assert_eq!(row.event_type, "approved"); + assert_eq!(row.status, "approved"); + } + + #[test] + fn sql_splitter_ignores_comments() { + let statements = split_sql_statements("-- comment\nSELECT 1;\nSELECT 2;"); + assert_eq!(statements, vec!["SELECT 1;", "SELECT 2;"]); + } + + #[test] + fn clickhouse_identifier_rejects_injection() { + assert_eq!( + clickhouse_identifier("analytics_1c").as_deref(), + Some("analytics_1c") + ); + assert!(clickhouse_identifier("analytics_1c;DROP").is_none()); + } + + #[test] + fn hayabusa_intake_maps_to_finding_row() { + let dir = tempdir().unwrap(); + let report_dir = dir.path().join("report"); + fs::create_dir_all(&report_dir).unwrap(); + fs::write( + report_dir.join("timeline.jsonl"), + r#"{"Level":"crit","RuleTitle":"Credential Dump","Timestamp":"2026-06-25T10:00:00Z"}"#, + ) + .unwrap(); + fs::write(report_dir.join("logon-summary-failed.csv"), "h\n1\n").unwrap(); + let intake_path = dir.path().join("latest-intake.json"); + fs::write( + &intake_path, + format!( + r#"{{ + "host": "HOST-EXAMPLE", + "status": "ok", + "intake_id": "test-intake", + "package_path": "{}/HOST-EXAMPLE.zip", + "sha256": "demo", + "report_dir": "{}" +}}"#, + dir.path().display(), + report_dir.display() + ), + ) + .unwrap(); + let rows = hayabusa_intake_rows(&intake_path, "low").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].source, "hayabusa"); + assert_eq!(rows[0].host, "HOST-EXAMPLE"); + assert_eq!(rows[0].severity, "critical"); + } + + #[test] + fn velociraptor_json_maps_to_finding_row() { + let value = json!({ + "Hostname": "HOST-EXAMPLE", + "Artifact": "Windows.Hayabusa.Monitoring", + "Severity": "high", + "Message": "suspicious workstation", + "User": "user-example" + }); + let row = velociraptor_value_to_row(&value, "medium", 0).unwrap(); + assert_eq!(row.source, "velociraptor"); + assert_eq!(row.host, "HOST-EXAMPLE"); + assert_eq!(row.severity, "high"); + assert_eq!(row.user, "user-example"); + } + + #[test] + fn executor_blocks_local_apply_for_other_host() { + let candidate = ExecutorCandidate { + finding_id: "sf-demo".to_string(), + host: "HOST-A".to_string(), + state: "suspected_infected".to_string(), + severity: "high".to_string(), + confidence: "high".to_string(), + source: "hayabusa".to_string(), + rule_id: "rule".to_string(), + rule_title: "Rule".to_string(), + summary: "summary".to_string(), + recommended_action: "windows_firewall_quarantine".to_string(), + management_channel_checked: 1, + evidence_ref: "demo".to_string(), + raw_json: "{}".to_string(), + }; + let client = ClickHouseClient::new( + "http://127.0.0.1:8123".to_string(), + "analytics_1c".to_string(), + "default".to_string(), + String::new(), + Duration::from_secs(1), + ) + .unwrap(); + let config = ExecutorConfig { + client, + policy: PathBuf::from("/tmp/policy.json"), + containment_engine_bin: PathBuf::from("containment-engine"), + work_dir: PathBuf::from("/tmp"), + management_allowlist: vec!["10.10.10.10".to_string()], + blocked_remote_addresses: vec!["10.10.20.0/24".to_string()], + profiles: vec!["Domain".to_string()], + limit: 1, + execute_local: true, + confirm_execute: "YES".to_string(), + executor_host: "HOST-B".to_string(), + dry_run: false, + }; + let blockers = executor_candidate_blockers(&config, &candidate); + assert!( + blockers + .iter() + .any(|item| item.starts_with("executor_host_mismatch")) + ); + } +} diff --git a/aw-server/hayabusa/README.md b/aw-server/hayabusa/README.md index bf17b5d..8707bb6 100644 --- a/aw-server/hayabusa/README.md +++ b/aw-server/hayabusa/README.md @@ -79,6 +79,13 @@ Behavior: - optional `*.caseid` sidecar with the same basename triggers automatic bounded case linkage - processed `*.zip` is moved out of `drop/` into `report_dir/input-drop/` to avoid repeated re-trigger loops - sidecars are archived into `report_dir/input-sidecars/` +- bad drop packages are rejected before `accept`, moved to + `/opt/hayabusa/quarantine/drop/_/`, and recorded with a + `reason.json` file instead of blocking later packages +- bad or partially extracted incoming packages are moved to + `/opt/hayabusa/quarantine/incoming/_/`; `process-inbox` + continues with the remaining queue and does not trip systemd start-limit only + because of one poison archive ## Windows direct upload into the drop zone @@ -118,4 +125,43 @@ Production scheduled task on `SHARKON2025`: Do not switch this task back to `SYSTEM` on the current RDP host: Task Scheduler starts `powershell.exe` under `SYSTEM`, but the process exits with `0xC0000142` before the upload script starts. -Server-side processing accepts Windows zip packages with backslash path separators and UTF-8 BOM in sidecar JSON. `aw-hayabusa-autoprocess` processes the full incoming queue after accepting a drop package, so stale incoming files from an earlier failed run are drained before the latest intake is recorded. +Server-side processing accepts Windows zip packages with backslash path +separators and UTF-8 BOM in sidecar JSON. `aw-hayabusa-autoprocess` processes +the full incoming queue after accepting a drop package, so stale incoming files +from an earlier failed run are drained before the latest intake is recorded. + +Poison-package handling is fail-closed: + +- Rust `aw-hayabusa-autoprocess-rust` validates the zip and sidecars before + calling `aw-hayabusa accept`. +- A corrupt/empty/unsafe drop package is quarantined with its `.meta.json`, + `.caseid`, optional checksum sidecar and `reason.json`. +- `aw-hayabusa process-inbox` isolates a failed incoming package instead of + aborting the whole batch. +- Operators replay only a fixed/re-exported package by moving it back to the + drop zone or incoming queue. Do not edit quarantined evidence in place. + +## Security Finding Inbox integration + +`aw-hayabusa-autoprocess-rust` can publish a normalized suspicious-workstation +finding after a successful intake is written to `/opt/hayabusa/state/latest-intake.json`. + +Default is disabled to keep forensic processing independent from ClickHouse: + +```bash +AW_SECURITY_FINDING_INBOX_ENABLED=false +``` + +Enable after the ClickHouse schema and CLI are installed: + +```bash +AW_SECURITY_FINDING_INBOX_ENABLED=true +AW_SECURITY_FINDING_INBOX_BIN=/usr/local/bin/security-finding-inbox +AW_SECURITY_FINDING_INBOX_MIN_SEVERITY=medium +AW_SECURITY_FINDING_INBOX_REQUIRED=false +``` + +With `AW_SECURITY_FINDING_INBOX_REQUIRED=false`, a temporary ClickHouse/inbox +failure is logged as warning and does not poison the Hayabusa backlog. Use +`true` only when the operator wants inbox publication failure to become an +operational failure for the drop service. diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh index a02bdd6..abde8dc 100644 --- a/aw-server/hayabusa/aw-hayabusa.sh +++ b/aw-server/hayabusa/aw-hayabusa.sh @@ -14,6 +14,7 @@ HAYA_STAGING_DIR="${AW_HAYABUSA_STAGING_DIR:-${HAYA_ROOT}/inbox/staging}" HAYA_ARCHIVE_PACKAGES_DIR="${AW_HAYABUSA_ARCHIVE_PACKAGES_DIR:-${HAYA_ROOT}/archive/packages}" HAYA_ARCHIVE_EXTRACTED_DIR="${AW_HAYABUSA_ARCHIVE_EXTRACTED_DIR:-${HAYA_ROOT}/archive/extracted}" HAYA_LOGS_DIR="${AW_HAYABUSA_LOGS_DIR:-${HAYA_ROOT}/state/logs}" +HAYA_QUARANTINE_DIR="${AW_HAYABUSA_QUARANTINE_DIR:-${HAYA_ROOT}/quarantine/incoming}" LAST_REPORT_DIR="" usage() { @@ -57,7 +58,8 @@ ensure_layout() { "${HAYA_INCOMING_DIR}" \ "${HAYA_STAGING_DIR}" \ "${HAYA_ARCHIVE_PACKAGES_DIR}" \ - "${HAYA_ARCHIVE_EXTRACTED_DIR}" + "${HAYA_ARCHIVE_EXTRACTED_DIR}" \ + "${HAYA_QUARANTINE_DIR}" } run_logged() { @@ -405,7 +407,8 @@ process_one_package() { package_sha256="$(sha256sum "${package_path}" | awk '{print $1}')" if ! extract_zip_normalized "${package_path}" "${stage_dir}"; then - fail "normalized zip extraction failed for ${package_path}" + echo "ERROR: normalized zip extraction failed for ${package_path}" >&2 + return 1 fi local manifest_path host evtx_root archive_pkg_dir archive_extract_dir status report_dir @@ -453,7 +456,47 @@ process_one_package() { if [ -n "${report_dir}" ]; then echo "Report directory: ${report_dir}" fi - [ "${status}" = "ok" ] || fail "Package workflow ended with status=${status}; archived for inspection" + if [ "${status}" != "ok" ]; then + echo "ERROR: Package workflow ended with status=${status}; archived for inspection" >&2 + return 1 + fi +} + +quarantine_incoming_package() { + local package_path="$1" + local reason="$2" + local ts package_name package_base safe_base target_dir stage_dir sha256 + ts="$(date -u +%Y%m%dT%H%M%SZ)" + package_name="$(basename "${package_path}")" + package_base="${package_name%.zip}" + safe_base="$(sanitize "${package_name}")" + [ -n "${safe_base}" ] || safe_base="package.zip" + target_dir="${HAYA_QUARANTINE_DIR}/${ts}_${safe_base}" + mkdir -p "${target_dir}" + sha256="" + if [ -f "${package_path}" ] && command -v sha256sum >/dev/null 2>&1; then + sha256="$(sha256sum "${package_path}" | awk '{print $1}')" + fi + for candidate in "${package_path}" "${package_path}.sha256" "${package_path}.host"; do + if [ -e "${candidate}" ]; then + mv "${candidate}" "${target_dir}/" + fi + done + stage_dir="${HAYA_STAGING_DIR}/${package_base}" + if [ -d "${stage_dir}" ]; then + mv "${stage_dir}" "${target_dir}/staging-partial" + fi + cat >"${target_dir}/reason.json" <&2 } process_inbox() { @@ -482,15 +525,26 @@ process_inbox() { esac ensure_layout - local count=0 pkg + local count=0 failed=0 pkg while IFS= read -r pkg; do - process_one_package "${pkg}" "${mode}" - count=$((count + 1)) + if process_one_package "${pkg}" "${mode}"; then + count=$((count + 1)) + else + failed=$((failed + 1)) + if [ -f "${pkg}" ]; then + quarantine_incoming_package "${pkg}" "process_one_package failed" + else + echo "Package failed after archive/move, see archive intake manifest for details: ${pkg}" >&2 + fi + fi if [ "${limit}" -gt 0 ] && [ "${count}" -ge "${limit}" ]; then break fi done < <(find "${HAYA_INCOMING_DIR}" -maxdepth 1 -type f -name '*.zip' | sort) [ "${count}" -gt 0 ] || echo "No packages in ${HAYA_INCOMING_DIR}" + if [ "${failed}" -gt 0 ]; then + echo "process-inbox completed with quarantined_or_archived_failures=${failed}" >&2 + fi } main() { diff --git a/clickhouse-1c/README.md b/clickhouse-1c/README.md index d6c631c..386ec97 100644 --- a/clickhouse-1c/README.md +++ b/clickhouse-1c/README.md @@ -66,6 +66,9 @@ File 1C + reglog + host telemetry - `grafana/provisioning/dashboards/files/1c-telemetry-board.json` — telemetry dashboard по состоянию файловых баз, reglog growth, busy markers и host load. - `detections/build_entity_timeline.sql` — сборка единого timeline слоя. - `detections/open_cases_from_detections.sql` — шаблон открытия cases из detections. +- `security/security_finding_inbox.sql` — schema Security Finding Inbox: + подозрительные станции, raw finding evidence, workflow/executor events и + latest-state view для DetMir Portal. - `ops/etl-cron.example` — legacy cron example; production использует `aw-1c-ingest.timer`. - `ops/retention-policy.md` — минимальная retention policy. diff --git a/clickhouse-1c/docker-compose.yml b/clickhouse-1c/docker-compose.yml index 19f5151..8efa91a 100644 --- a/clickhouse-1c/docker-compose.yml +++ b/clickhouse-1c/docker-compose.yml @@ -10,6 +10,16 @@ services: ports: - "${CLICKHOUSE_PORT}:8123" - "${CLICKHOUSE_NATIVE_PORT}:9000" + healthcheck: + test: + [ + "CMD-SHELL", + "clickhouse-client --host 127.0.0.1 --user \"$${CLICKHOUSE_USER}\" --password \"$${CLICKHOUSE_PASSWORD}\" --database \"$${CLICKHOUSE_DB}\" --query 'SELECT 1' >/dev/null", + ] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s volumes: - clickhouse_1c_data:/var/lib/clickhouse - ./clickhouse/init:/docker-entrypoint-initdb.d:ro diff --git a/clickhouse-1c/ops/run_ingest_cycle.sh b/clickhouse-1c/ops/run_ingest_cycle.sh index 07a9f65..ba1c6f1 100644 --- a/clickhouse-1c/ops/run_ingest_cycle.sh +++ b/clickhouse-1c/ops/run_ingest_cycle.sh @@ -76,6 +76,14 @@ docker exec -i "${CH_CONTAINER}" clickhouse-client \ --database "${CLICKHOUSE_DB}" \ < "${ROOT}/detections/open_cases_from_detections.sql" +if [[ -f "${ROOT}/security/security_finding_inbox.sql" ]]; then + docker exec -i "${CH_CONTAINER}" clickhouse-client \ + --user "${CLICKHOUSE_USER}" \ + --password "${CLICKHOUSE_PASSWORD}" \ + --database "${CLICKHOUSE_DB}" \ + < "${ROOT}/security/security_finding_inbox.sql" +fi + if [[ "${RUN_MANAGER_BRIEF_AFTER_INGEST}" == "1" ]]; then if ! "${ROOT}/ops/run_manager_brief.sh"; then echo "warning: manager brief refresh failed after ingest" >&2 diff --git a/clickhouse-1c/security/security_finding_inbox.sql b/clickhouse-1c/security/security_finding_inbox.sql new file mode 100644 index 0000000..5485de0 --- /dev/null +++ b/clickhouse-1c/security/security_finding_inbox.sql @@ -0,0 +1,97 @@ +CREATE TABLE IF NOT EXISTS analytics_1c.security_findings +( + ts DateTime64(3, 'UTC'), + finding_id String, + host String, + user String, + ip String, + department LowCardinality(String), + state LowCardinality(String), + severity LowCardinality(String), + confidence LowCardinality(String), + score UInt16, + source LowCardinality(String), + rule_id String, + rule_title String, + summary String, + recommended_action LowCardinality(String), + management_channel_checked UInt8, + evidence_ref String, + raw_json String, + ingested_at DateTime64(3, 'UTC') DEFAULT now64(3) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(ts) +ORDER BY (state, severity, host, ts, finding_id); + +CREATE TABLE IF NOT EXISTS analytics_1c.security_finding_workflow_events +( + ts DateTime64(3, 'UTC'), + finding_id String, + event_type LowCardinality(String), + status LowCardinality(String), + actor String, + comment String, + decision_status String, + rollback_plan_id String, + plan_id String, + evidence_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(ts) +ORDER BY (finding_id, ts, event_type); + +DROP VIEW IF EXISTS analytics_1c.security_finding_inbox; + +CREATE VIEW analytics_1c.security_finding_inbox AS +SELECT + f.finding_id AS finding_id, + min(f.ts) AS first_seen, + max(f.ts) AS last_seen, + argMax(f.host, f.ingested_at) AS host, + argMax(f.user, f.ingested_at) AS user, + argMax(f.ip, f.ingested_at) AS ip, + argMax(f.department, f.ingested_at) AS department, + argMax(f.state, f.ingested_at) AS state, + argMax(f.severity, f.ingested_at) AS severity, + argMax(f.confidence, f.ingested_at) AS confidence, + argMax(f.score, f.ingested_at) AS score, + argMax(f.source, f.ingested_at) AS source, + argMax(f.rule_id, f.ingested_at) AS rule_id, + argMax(f.rule_title, f.ingested_at) AS rule_title, + argMax(f.summary, f.ingested_at) AS summary, + argMax(f.recommended_action, f.ingested_at) AS recommended_action, + argMax(f.management_channel_checked, f.ingested_at) AS management_channel_checked, + argMax(f.evidence_ref, f.ingested_at) AS evidence_ref, + argMax(f.raw_json, f.ingested_at) AS raw_json, + coalesce(nullIf(w.status, ''), 'new') AS workflow_status, + coalesce(nullIf(w.event_type, ''), 'created') AS last_workflow_event, + w.workflow_updated_at AS workflow_updated_at, + coalesce(w.actor, '') AS workflow_actor, + coalesce(w.decision_status, '') AS decision_status, + coalesce(w.rollback_plan_id, '') AS rollback_plan_id, + coalesce(w.plan_id, '') AS plan_id +FROM analytics_1c.security_findings AS f +LEFT JOIN +( + SELECT + finding_id, + argMax(event_type, ts) AS event_type, + argMax(status, ts) AS status, + argMax(actor, ts) AS actor, + argMax(decision_status, ts) AS decision_status, + argMax(rollback_plan_id, ts) AS rollback_plan_id, + argMax(plan_id, ts) AS plan_id, + max(ts) AS workflow_updated_at + FROM analytics_1c.security_finding_workflow_events + GROUP BY finding_id +) AS w USING finding_id +GROUP BY + f.finding_id, + w.status, + w.event_type, + w.workflow_updated_at, + w.actor, + w.decision_status, + w.rollback_plan_id, + w.plan_id; diff --git a/configs/containment-finding.example.json b/configs/containment-finding.example.json new file mode 100644 index 0000000..11ef1be --- /dev/null +++ b/configs/containment-finding.example.json @@ -0,0 +1,21 @@ +{ + "host": "HOST-EXAMPLE", + "host_role": "workstation", + "state": "suspected_infected", + "confidence": "high", + "signals": [ + { + "source": "hayabusa", + "rule_id": "sigma-placeholder-critical", + "confidence": "critical" + }, + { + "source": "velociraptor", + "rule_id": "Windows.Hayabusa.Monitoring", + "confidence": "high" + } + ], + "recommended_action": "windows_firewall_quarantine", + "management_channel_checked": true, + "manual_operator_flag": false +} diff --git a/configs/containment-policy.example.json b/configs/containment-policy.example.json new file mode 100644 index 0000000..8e4fa1e --- /dev/null +++ b/configs/containment-policy.example.json @@ -0,0 +1,17 @@ +{ + "enabled": false, + "mode": "shadow", + "default_ttl_minutes": 60, + "require_admin_channel_check": true, + "allow_auto_for_servers": false, + "allowed_actions": [ + "windows_firewall_quarantine", + "pfsense_host_block" + ], + "management_allowlist": [ + "aw_server", + "velociraptor_server", + "admin_jump_host" + ], + "minimum_high_signals_for_auto": 2 +} diff --git a/configs/security/security-finding.example.json b/configs/security/security-finding.example.json new file mode 100644 index 0000000..52c46c5 --- /dev/null +++ b/configs/security/security-finding.example.json @@ -0,0 +1,21 @@ +{ + "ts": "2026-06-25T10:00:00Z", + "host": "HOST-EXAMPLE", + "user": "user-example", + "ip": "10.10.20.42", + "department": "demo", + "state": "suspected_infected", + "severity": "critical", + "confidence": "high", + "score": 95, + "source": "hayabusa", + "rule_id": "demo-sigma-critical", + "rule_title": "Demo high-confidence suspicious workstation", + "summary": "Demo finding for Security Finding Inbox validation.", + "recommended_action": "windows_firewall_quarantine", + "management_channel_checked": true, + "evidence_ref": "demo://hayabusa/HOST-EXAMPLE/demo-sigma-critical", + "metadata": { + "sample": "true" + } +} diff --git a/configs/windows-firewall-containment-request.example.json b/configs/windows-firewall-containment-request.example.json new file mode 100644 index 0000000..71715f3 --- /dev/null +++ b/configs/windows-firewall-containment-request.example.json @@ -0,0 +1,18 @@ +{ + "target_host": "HOST-EXAMPLE", + "plan_id": "rollback-host-example-001", + "ttl_minutes": 60, + "reason": "High-confidence Hayabusa and Velociraptor containment drill", + "management_allowlist": [ + "10.10.10.10", + "10.10.10.11", + "10.10.10.12" + ], + "blocked_remote_addresses": [ + "10.10.20.0/24", + "10.10.30.0/24" + ], + "profiles": [ + "Domain" + ] +} diff --git a/docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md b/docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md new file mode 100644 index 0000000..05e4a1b --- /dev/null +++ b/docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md @@ -0,0 +1,194 @@ +# AWatch-rus containment operator runbook + +Дата: 2026-06-25. + +Runbook для безопасной проверки containment-логики. Текущая реализация не +блокирует рабочие станции и не меняет сеть. Она только рассчитывает решение и +показывает, был бы quarantine рекомендован или отказан. + +## 1. Сборка + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +export CARGO_TARGET_DIR=/home/igor/.cache/detmir-adk-rust-target +cargo build --manifest-path adk-rust/Cargo.toml -p containment-engine +``` + +## 2. Smoke в disabled/shadow режиме + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +bash scripts/containment_shadow_smoke.sh +``` + +Ожидаемо: + +- JSON содержит `would_mutate=false`; +- `decision_status=disabled` для default example policy; +- нет изменений firewall, pfSense, AD, VLAN, routes. + +## 3. Проверка shadow recommendation + +Создайте временный policy с: + +```json +{ + "enabled": true, + "mode": "shadow" +} +``` + +на базе `configs/containment-policy.example.json`, затем выполните: + +```bash +containment-engine decide \ + --policy /tmp/containment-policy-shadow.json \ + --finding configs/containment-finding.example.json \ + --pretty +``` + +Ожидаемо: + +- `decision_status=shadow_recommended`; +- `would_mutate=false`; +- `rollback_plan_id` заполнен; +- `blockers=[]`. + +## 4. Manual approval mode + +`manual_approval` должен только поставить решение в состояние +`manual_approval_required`. Он не применяет block сам. + +## 5. Auto mode + +В текущей реализации `auto` может вернуть `auto_ready`, но `would_mutate=false`. +Это намеренно: decision layer сам не применяет блокировки. + +Запрещено считать `auto_ready` фактической блокировкой. Это только решение +control plane. + +## 6. Windows Firewall executor dry-run + +Security Finding Inbox показывает подозрительные станции и фиксирует workflow +события. Портал не выполняет firewall apply. После `approved` и +`apply_requested` отдельный процесс `security-finding-inbox executor` может +выполнить контролируемый цикл `decide -> plan -> apply -> verify`, а при +ошибке `rollback`. По умолчанию executor работает dry-run/fail-closed. + +Сгенерируйте план: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +containment-engine windows-firewall plan \ + --request configs/windows-firewall-containment-request.example.json \ + --pretty > /tmp/windows-firewall-plan.json +``` + +Проверьте `blockers`. Для корректного example они должны быть пустыми. + +Dry-run apply: + +```bash +containment-engine windows-firewall apply \ + --plan /tmp/windows-firewall-plan.json \ + --confirm-apply YES \ + --pretty +``` + +Ожидаемо: + +- `execution_status=dry_run_commands_ready`; +- `would_mutate=false`; +- в JSON есть PowerShell-команды `New-NetFirewallRule`; +- реальные firewall-правила не создаются. + +Verify dry-run: + +```bash +containment-engine windows-firewall verify \ + --plan /tmp/windows-firewall-plan.json \ + --pretty +``` + +Rollback dry-run: + +```bash +containment-engine windows-firewall rollback \ + --plan /tmp/windows-firewall-plan.json \ + --confirm-rollback YES \ + --pretty +``` + +## 7. Real Windows execution rules + +Dry-run polling из центрального контура: + +```bash +security-finding-inbox executor \ + --once \ + --dry-run \ + --containment-engine-bin /usr/local/bin/containment-engine \ + --policy /etc/activitywatch/containment-policy.json \ + --management-allowlist 10.10.10.10,10.10.10.11 \ + --blocked-remote-addresses 10.10.20.0/24,10.10.30.0/24 +``` + +Реальный Windows Firewall apply допускается только на целевой Windows-станции: + +```powershell +security-finding-inbox.exe executor ` + --once ` + --execute-local ` + --confirm-execute YES ` + --executor-host HOST-EXAMPLE ` + --containment-engine-bin C:\ProgramData\AWatch-rus\containment-engine.exe ` + --policy C:\ProgramData\AWatch-rus\containment-policy.json ` + --management-allowlist 10.10.10.10,10.10.10.11 ` + --blocked-remote-addresses 10.10.20.0/24,10.10.30.0/24 +``` + +Executor откажется, если нет `approved` перед `apply_requested`, finding не +`suspected_infected`/`confirmed_infected`, management channel не проверен, +allowlist/block ranges пустые, host finding не совпадает с executor host для +local apply, containment policy возвращает blocker или Windows Firewall plan +содержит blockers. + +`--execute-local` разрешён только для отдельного lab Windows host, где заранее +проверены: + +- доступ с admin jump host; +- доступ к AWatch/Velociraptor management адресам; +- rollback command; +- out-of-band доступ, если firewall rule ошибочен; +- TTL и оператор, ответственный за возврат. + +Не использовать широкие блокировки `Any`/`LocalSubnet`: Windows Firewall +block-правила могут перекрыть allow-правила и отрезать управление. + +## 8. Когда можно расширять real containment executor + +Только после выполнения условий: + +- есть lab host; +- подтвержден management allowlist; +- есть rollback command; +- есть TTL rollback; +- есть audit log; +- `plan`, `apply`, `verify`, `rollback` покрыты тестами; +- auto-containment для серверов остается disabled. + +## 9. Проверки перед commit + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +python3 scripts/public_secret_pattern_check.py +bash -n scripts/containment_shadow_smoke.sh +bash scripts/containment_shadow_smoke.sh +git diff --check + +cd adk-rust +export CARGO_TARGET_DIR=/home/igor/.cache/detmir-adk-rust-target +cargo fmt --all --check +cargo test -p containment-engine +cargo clippy -p containment-engine --all-targets -- -D warnings +``` diff --git a/docs/CONTAINMENT_POLICY_RU.md b/docs/CONTAINMENT_POLICY_RU.md new file mode 100644 index 0000000..39664b1 --- /dev/null +++ b/docs/CONTAINMENT_POLICY_RU.md @@ -0,0 +1,146 @@ +# AWatch-rus containment policy + +Дата: 2026-06-25. + +Этот документ описывает безопасную политику автоматической/полуавтоматической +изоляции рабочих станций. Containment нужен для быстрого ограничения +распространения заражения, но не является автоматическим лечением, +remediation, EDR/XDR или сертифицированной СЗИ. + +## Default posture + +По умолчанию containment выключен: + +```text +AW_CONTAINMENT_ENABLED=false +AW_CONTAINMENT_MODE=shadow +``` + +`shadow` означает: система рассчитывает рекомендацию и audit, но не меняет +firewall, pfSense, AD, VLAN, маршруты или состояние рабочих станций. + +## Policy file + +Default path: + +```text +/etc/activitywatch/containment-policy.json +``` + +Repo example: + +```text +configs/containment-policy.example.json +``` + +Критичные поля: + +- `enabled`: глобальный opt-in; +- `mode`: `shadow`, `manual_approval`, `auto`; +- `default_ttl_minutes`: срок quarantine до rollback/review; +- `require_admin_channel_check`: запрещает блокировку, если управляемый канал + не проверен; +- `allow_auto_for_servers`: по умолчанию `false`; +- `allowed_actions`: whitelist containment-действий; +- `management_allowlist`: каналы, которые должны оставаться доступными; +- `minimum_high_signals_for_auto`: минимальный порог high/critical signals. + +## Safety rules + +- Не включать `auto` до успешного shadow burn-in. +- Не включать auto-containment для серверов и domain controllers. +- Не запускать containment без rollback record. +- Не запускать containment, если будет потерян admin/management channel. +- Не применять широкие AD/OU/domain actions. +- Не удалять файлы, registry keys или процессы как часть containment. +- Не заявлять, что containment гарантированно остановил заражение. + +## Decision threshold + +Automatic quarantine допускается только если: + +- host role is `workstation`; +- host не входит в critical infrastructure denylist; +- есть один `critical` signal или несколько `high` signals; +- management-channel precheck passed; +- action есть в `allowed_actions`; +- rollback record создан успешно. + +## Security Finding Inbox handoff + +Security Finding Inbox (`docs/SECURITY_FINDING_INBOX_RU.md`) является входной +очередью для подозрительных рабочих станций. Он хранит findings и workflow +events в ClickHouse, показывает их в DetMir Portal. Портал не выполняет +containment самостоятельно. + +Workflow `apply_requested` означает только операторский запрос на применение. +Фактическое применение идет через отдельный процесс +`security-finding-inbox executor`, который повторно проверяет `approved`, +запускает `containment-engine decide`, строит Windows Firewall plan, затем +выполняет `apply`, `verify` и при ошибке `rollback`. Реальная мутация firewall +разрешена только на целевой Windows-станции при `--execute-local`, +`--confirm-execute YES` и совпадении `--executor-host` с finding host. + +## Current implementation status + +Реализован первый безопасный слой: + +- Rust CLI `containment-engine`; +- strict JSON policy/finding parsing; +- `disabled`, `shadow`, `manual_approval`, `auto` decision states; +- server/unknown host roles refused for auto mode by default; +- `would_mutate=false` for current implementation; +- separate Windows Firewall executor interface: + `plan`, `apply`, `verify`, `rollback`; +- Windows Firewall executor defaults to dry-run command generation unless + `--execute-local` and explicit confirmation are used. + +pfSense/AD/VLAN mutation paths are not implemented. + +## Windows Firewall executor + +Executor input example: + +```text +configs/windows-firewall-containment-request.example.json +``` + +The executor is deliberately separate from decision making: + +```bash +containment-engine windows-firewall plan \ + --request configs/windows-firewall-containment-request.example.json \ + --pretty > /tmp/windows-firewall-plan.json + +containment-engine windows-firewall apply \ + --plan /tmp/windows-firewall-plan.json \ + --confirm-apply YES \ + --pretty + +containment-engine windows-firewall verify \ + --plan /tmp/windows-firewall-plan.json \ + --pretty + +containment-engine windows-firewall rollback \ + --plan /tmp/windows-firewall-plan.json \ + --confirm-rollback YES \ + --pretty +``` + +Without `--execute-local`, `apply` and `rollback` return generated PowerShell +commands and `would_mutate=false`. + +With `--execute-local`, execution is allowed only on a Windows host and only +after explicit confirmation. On non-Windows hosts the executor fails closed. + +## Windows Firewall guardrails + +- `management_allowlist` is mandatory. +- `blocked_remote_addresses` must be explicit IPs/subnets. +- Broad block targets such as `Any`, `*`, `LocalSubnet`, `Internet`, + `Intranet` are refused. +- The executor does not change Windows Firewall profile defaults. +- The executor does not disable interfaces, routes, users, services or + processes. +- Every plan includes rollback through `Remove-NetFirewallRule -Group ...`. +- A successful dry-run is not evidence that the workstation has been isolated. diff --git a/docs/LOW_COST_SIGMA_HAYABUSA_VELOCIRAPTOR_ADDON_RU.md b/docs/LOW_COST_SIGMA_HAYABUSA_VELOCIRAPTOR_ADDON_RU.md new file mode 100644 index 0000000..264d47b --- /dev/null +++ b/docs/LOW_COST_SIGMA_HAYABUSA_VELOCIRAPTOR_ADDON_RU.md @@ -0,0 +1,813 @@ +# Low-cost Sigma/Hayabusa/Velociraptor containment addon + +Дата: 2026-06-25. + +Цель: добавить в AWatch-rus дешевый, воспроизводимый и отключаемый слой +security containment + forensics для организаций без зрелого SIEM/EDR. Главный +смысл модуля - быстро ограничить дальнейшее распространение заражения или +подозрительной активности с рабочей станции, сохранив управляемый канал +расследования и восстановления. + +Это дополнение не делает AWatch-rus сертифицированной DLP/SIEM/EDR/XDR/СЗИ и +не заменяет штатные средства защиты. Автоматическая блокировка здесь означает +policy-approved containment/quarantine, а не автоматическое лечение системы. + +## Upstream basis + +- Hayabusa: fast Windows event log forensics timeline generator and threat + hunting tool, written in Rust, using Sigma-compatible Hayabusa rules. +- Hayabusa supports single-host/live analysis, offline analysis of collected + logs, and enterprise-wide use through a Velociraptor artifact. +- Hayabusa outputs timeline/results suitable for CSV, JSON/JSONL and HTML + reports. +- `Windows.Hayabusa.Monitoring` in Velociraptor Curated Sigma is an artifact + intended to triage a Windows host and is based on `Windows.Sigma.BaseEvents`. +- Velociraptor is an endpoint visibility and collection tool using VQL + artifacts. Its normal deployment is server + clients, but it also supports + offline collectors and command-line artifact execution. + +Primary references: + +- https://github.com/Yamato-Security/hayabusa +- https://github.com/Yamato-Security/hayabusa/wiki/About-Hayabusa +- https://github.com/Yamato-Security/hayabusa-rules +- https://sigma.velocidex.com/docs/artifacts/windows.hayabusa.monitoring/ +- https://github.com/Velocidex/velociraptor +- https://docs.velociraptor.app/docs/deployment/ + +## Product positioning + +Рабочее название модуля: + +```text +AWatch-rus Low-Cost Containment Pack +``` + +Назначение: + +- быстро получить полезный containment + DFIR/threat-hunting слой там, где + нет SIEM/EDR; +- автоматически или полуавтоматически изолировать подозрительно зараженную + рабочую станцию от критичных сегментов; +- сохранить минимальный управляемый канал: AWatch-rus/Velociraptor server, + администраторский jump/VPN, DNS/NTP при необходимости; +- запускать Hayabusa/Sigma-анализ EVTX и Velociraptor artifact collection; +- давать владельцу и администратору понятные findings, timeline и evidence; +- связывать findings с AWatch-rus cases и operator/forensics views; +- оставаться optional и выключаемым без деградации Workforce core. + +Запрещенные claims: + +- не писать, что это SIEM replacement; +- не писать, что это DLP replacement; +- не писать, что это EDR/XDR; +- не писать, что это сертифицированная СЗИ; +- не писать, что автоматическое remediation включено; +- не писать, что automatic containment гарантированно остановит заражение; +- не писать, что threat detection ML/LLM-based; +- не писать, что найденные события являются доказанной атакой без ручной + проверки. + +Допустимая формулировка: + +```text +Optional low-cost containment, security analytics and forensics layer based on +open-source Hayabusa/Sigma/Velociraptor workflows. +``` + +## Containment objective + +Модуль должен отвечать на вопрос: + +```text +Как максимально быстро ограничить рабочую станцию, которая выглядит зараженной, +чтобы она не заражала соседние машины и не продолжала утечку/распространение? +``` + +Необходимо разделять: + +- `suspected_infected` - есть правила/сигналы/аномалии, достаточные для + карантина по политике организации; +- `confirmed_infected` - есть ручное подтверждение администратора/ИБ; +- `contained` - станция технически ограничена; +- `released` - карантин снят вручную или по документированному rollback. + +Containment actions должны быть обратимыми, журналируемыми и ограниченными по +blast radius. По умолчанию допускается `shadow` или `manual_approval`; fully +automatic quarantine включается только отдельным флагом и только после +allowlist/rollback проверки. + +## Architecture + +### Modes + +1. `disabled` + - default для conservative deployment; + - все readiness checks возвращают disabled-state; + - Workforce/ActivityWatch core не зависит от модуля. + +2. `hayabusa_offline` + - текущий базовый режим; + - Windows scheduled task экспортирует EVTX zip; + - серверный `aw-hayabusa-drop.path` принимает package; + - `aw-hayabusa-autoprocess` валидирует zip, process-inbox, quarantine. + +3. `velociraptor_offline_collector` + - для бедных/малых контуров без постоянно работающего Velociraptor server; + - AWatch-rus собирает/хранит signed offline collector bundle; + - запуск collector выполняется вручную или scheduled task; + - результаты импортируются как artifact bundle. + +4. `velociraptor_server_clients` + - optional managed mode; + - Linux server рядом с AW/Proxmox или отдельной VM; + - Windows clients ставятся только явным Ansible-флагом; + - используется для управляемого запуска `Windows.Hayabusa.Monitoring`. + +5. `containment_shadow` + - decision engine считает, что сделал бы, но ничего не блокирует; + - безопасный default для пилота; + - используется для настройки правил и false-positive анализа. + +6. `containment_manual_approval` + - система формирует containment recommendation; + - администратор подтверждает действие в CLI/портале; + - все действия пишутся в audit trail. + +7. `containment_auto` + - система сама применяет заранее разрешенные quarantine-действия; + - включается только явным флагом; + - требует allowlist, rollback TTL и проверку сохранения admin channel. + +### Boundaries + +Core remains: + +- ActivityWatch server; +- Workforce reports; +- RDP/window/AFK/worktime collectors; +- 1C/ClickHouse analytics; +- portal health/readiness; +- Hayabusa drop quarantine hardening. + +Optional containment/forensics layer: + +- Hayabusa binary and rules; +- Sigma/Hayabusa curated rules cache; +- Velociraptor binary/config/artifacts; +- Velociraptor clients/offline collectors; +- artifact result import; +- findings summary and case links; +- containment decision engine; +- containment executor for approved channels. + +Containment channels: + +- Windows host firewall quarantine: + allow only AWatch-rus/Velociraptor server, DNS/NTP if required, and admin + jump/VPN; +- pfSense/network gateway block: + block workstation IP/MAC from lateral/internal segments, keep management + exception; +- switch/VLAN quarantine when supported: + move port/client to quarantine VLAN through explicit integration; +- Windows local containment: + stop risky shares/services, disable outbound SMB/RDP to peers, collect + evidence; +- Active Directory actions, if configured: + disable only the workstation account or user session by policy, never broad + OU/domain actions by default. + +Non-goals: + +- deleting malware; +- cleaning registry/files; +- killing arbitrary processes based on weak signal; +- disabling domain-wide accounts; +- blocking servers/shared infrastructure automatically; +- hiding the host from administrators. + +No hot-path dependency: + +- portal first screen must not wait for Velociraptor; +- Workforce reports must not query Velociraptor; +- readiness must not fail when module is disabled; +- heavy artifact execution must be timer/manual/background only. + +## Proposed configuration + +Ansible group vars: + +```yaml +aw_forensics_pack_enabled: false +aw_hayabusa_enabled: true +aw_hayabusa_rules_enabled: true +aw_hayabusa_rules_version: "pinned" +aw_hayabusa_rules_update_enabled: false + +aw_velociraptor_enabled: false +aw_velociraptor_mode: "disabled" # disabled|offline_collector|server_clients +aw_velociraptor_version: "pinned" +aw_velociraptor_server_bind_host: "127.0.0.1" +aw_velociraptor_public_enabled: false +aw_velociraptor_artifact_pack_enabled: true +aw_velociraptor_hayabusa_artifact_enabled: true + +aw_forensics_store_raw_artifacts: false +aw_forensics_raw_retention_days: 7 +aw_forensics_result_retention_days: 90 +aw_forensics_max_parallel_jobs: 1 +aw_forensics_max_job_minutes: 30 +aw_forensics_cpu_quota_pct: 25 +aw_forensics_io_nice: true + +aw_containment_enabled: false +aw_containment_mode: "shadow" # shadow|manual_approval|auto +aw_containment_default_ttl_minutes: 60 +aw_containment_require_admin_channel_check: true +aw_containment_allow_auto_for_servers: false +aw_containment_allowed_actions: + - windows_firewall_quarantine + - pfsense_host_block +aw_containment_management_allowlist: + - "aw_server" + - "velociraptor_server" + - "admin_jump_host" +``` + +Runtime env: + +```text +AW_FORENSICS_PACK_ENABLED=false +AW_HAYABUSA_ENABLED=true +AW_VELOCIRAPTOR_ENABLED=false +AW_VELOCIRAPTOR_MODE=disabled +AW_FORENSICS_STORE_RAW_ARTIFACTS=false +AW_CONTAINMENT_ENABLED=false +AW_CONTAINMENT_MODE=shadow +``` + +## Data flow + +### Existing Hayabusa path + +```text +Windows EVTX export + -> zip + sidecars + -> /opt/activitywatch/aw-rus-ops/drop + -> aw-hayabusa-autoprocess + -> validate package + -> accept/process-inbox + -> result_dir/latest-intake.json + -> case link / portal summary + -> quarantine on bad package +``` + +### New Velociraptor path + +```text +Velociraptor artifact run + -> Windows.Hayabusa.Monitoring / custom artifact + -> Velociraptor result export + -> AWatch-rus import directory + -> schema validation + -> derived findings JSON/SQLite + -> optional case link + -> portal forensics summary +``` + +### Containment path + +```text +Finding/signals + -> confidence and policy evaluation + -> containment decision record + -> admin-channel precheck + -> shadow/manual/auto execution + -> verify containment + -> case/audit record + -> TTL/rollback queue +``` + +Raw artifacts and derived results must be separated: + +- raw EVTX/result bundles: restricted evidence storage; +- derived findings: sanitized AWatch-rus views; +- operator notes/case links: case database; +- public/demo exports: no raw hostnames, users, IPs, paths or secrets. + +## Security and privacy guardrails + +- Store no secrets in repo, docs, demo data or screenshots. +- Do not commit generated Velociraptor config with private keys/client secrets. +- Do not expose Velociraptor GUI publicly by default. +- Default server bind should be loopback or private VPN-only address. +- Require explicit operator action for client deployment. +- Require retention policy for raw artifacts. +- Require redaction for export/demo packs. +- Require audit log for artifact imports, deletes and case links. +- Treat Velociraptor outputs as untrusted input: validate schema, size, paths + and timestamps before import. +- Never execute arbitrary downloaded artifacts without pinning/checksums. +- Never run containment if management channel would be lost. +- Never auto-contain servers unless explicitly allowed and tested. +- Always create rollback record before applying a block. +- Always include TTL or manual release path. +- Always log who/what triggered containment, which signals were used and which + network paths remain allowed. + +## Containment decision model + +Inputs: + +- high/critical Hayabusa/Sigma rule hits; +- suspicious Windows event sequence from Velociraptor artifact; +- AWatch-rus endpoint signals such as mass file changes, unusual process/file + behavior, DLP/security signal spikes; +- administrator manual flag. + +Decision fields: + +```json +{ + "host": "HOST", + "host_role": "workstation", + "state": "suspected_infected", + "confidence": "medium|high|critical", + "signals": ["hayabusa:rule-id", "velociraptor:artifact"], + "recommended_action": "windows_firewall_quarantine", + "mode": "shadow|manual_approval|auto", + "ttl_minutes": 60, + "management_channel_checked": true, + "rollback_plan_id": "opaque-id" +} +``` + +Minimum threshold for automatic quarantine: + +- host role is workstation; +- host is not in denylist of critical infrastructure; +- at least one critical signal or multiple high-confidence signals; +- management channel precheck passed; +- containment action is in allowlist; +- rollback record successfully written. + +## Current implementation status + +Implemented first safe layer: + +- Rust CLI `containment-engine`; +- strict JSON parsing for policy/finding input; +- example files: + `configs/containment-policy.example.json`, + `configs/containment-finding.example.json`, + `configs/windows-firewall-containment-request.example.json`; +- disabled-by-default Ansible/env configuration; +- `shadow`, `manual_approval` and `auto` decision states; +- automatic containment refused for non-workstation roles by default; +- `would_mutate=false` in current implementation; +- Windows Firewall executor interface: + `plan`, `apply`, `verify`, `rollback`; +- Windows Firewall dry-run generates PowerShell `New-NetFirewallRule`, + `Get-NetFirewallRule` and `Remove-NetFirewallRule` commands; +- Windows Firewall execution is fail-closed without explicit confirmation and + `--execute-local`; +- Security Finding Inbox: + ClickHouse schema, Rust ingest CLI, Hayabusa/Velociraptor source adapters, + portal page `Подозрительные станции` and separate executor process for + approved `apply_requested` workflow; +- smoke script: + `bash scripts/containment_shadow_smoke.sh`; +- operator/policy docs: + `docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md`, + `docs/CONTAINMENT_POLICY_RU.md`. + +Not implemented yet: + +- production-verified Windows Firewall mutation on lab/real workstations; +- real pfSense alias/table mutation; +- AD/VLAN executor; +- TTL rollback service; +- portal containment action execution. The current portal records workflow + events only and does not mutate firewall/network state; mutation is reserved + for `security-finding-inbox executor` with explicit local Windows + confirmation. + +## Codex implementation plan + +### Phase 0. Architecture and docs only + +Files: + +- `docs/LOW_COST_SIGMA_HAYABUSA_VELOCIRAPTOR_ADDON_RU.md`; +- `docs/PROJECT_STATUS_RU.md`; +- `docs/REGISTRY_FUNCTIONAL_SCOPE_RU.md`; +- `README.md`. + +Tasks: + +- record addon scope and non-goals; +- document upstream references and license/supply-chain review requirement; +- state that module is planned/optional until implemented; +- keep forbidden SIEM/DLP/СЗИ claims blocked. + +Acceptance: + +- docs mention optional low-cost containment/forensics layer; +- no runtime/API/UI/product code change; +- secret scan and diff check pass. + +### Phase 1. Inventory current Hayabusa implementation + +Files: + +- `aw-server/hayabusa/README.md`; +- `aw-server/hayabusa/aw-hayabusa.sh`; +- `adk-rust/crates/hayabusa-tools/`; +- `windows/export-evtx-for-hayabusa.ps1`; +- `windows/export-upload-hayabusa-to-aw-server.ps1`; +- `ansible/deploy_aw_server.yml`; +- `ansible/deploy_aw_windows.yml`. + +Tasks: + +- document installed binaries, units, timers, directories and retention; +- verify current drop/inbox/quarantine behavior; +- add a manifest file for current Hayabusa server bundle; +- add a read-only status command if missing. + +Acceptance: + +- `aw-hayabusa doctor` remains green; +- bad zip quarantine behavior remains intact; +- no change to DLP disabled runtime state. + +### Phase 2. Supply-chain manifest and pinned downloads + +New files: + +- `third_party/forensics/manifest.json`; +- `scripts/prepare_forensics_binaries.sh`; +- `docs/FORENSICS_SUPPLY_CHAIN_RU.md`. + +Tasks: + +- define pinned versions for Hayabusa, Hayabusa rules and Velociraptor; +- define SHA256 checksums and source URLs; +- support offline cache directory; +- fail closed if checksum mismatch; +- never auto-update rules in production unless explicitly enabled. + +Acceptance: + +- dry-run prints planned downloads only; +- checksum verification works on cached fixture; +- no network required for deploy when cache exists. + +### Phase 3. Optional Velociraptor server install + +New/changed files: + +- `ansible/group_vars/all.yml`; +- `ansible/group_vars/all.example.yml`; +- `ansible/deploy_aw_server.yml`; +- `ops/systemd/velociraptor.service`; +- `docs/VELOCIRAPTOR_DEPLOYMENT_RU.md`. + +Tasks: + +- add `aw_velociraptor_enabled=false` default; +- install Velociraptor binary only when enabled; +- generate config only on target host, not in repo; +- bind to loopback/private address by default; +- store datastore under `/var/lib/velociraptor`; +- store config under `/etc/velociraptor`; +- add systemd service with resource limits; +- avoid public exposure unless explicitly configured. + +Acceptance: + +- disabled mode creates no running service; +- enabled mode installs service and returns local health; +- generated config is not committed; +- Ansible syntax check passes. + +### Phase 4. Velociraptor client/offline collector packaging + +Files: + +- `ansible/deploy_aw_windows.yml`; +- `windows/ActivityWatch.Windows.Common.psm1`; +- `windows/validate-deployment.ps1`; +- optional `windows/install-velociraptor-client.ps1`. + +Tasks: + +- add explicit deployment mode: + `disabled|offline_collector|client_service`; +- package client installer/offline collector from pinned binary/config; +- install client service only when explicitly enabled; +- keep scheduled/manual offline collector for low-cost mode; +- log to `C:\ProgramData\AWatch-rus\logs\velociraptor-*.log`; +- include service/task checks in validation only when enabled. + +Acceptance: + +- disabled mode leaves Windows host untouched; +- offline collector can run and produce an export bundle; +- service mode reports healthy enrollment without exposing credentials. + +### Phase 5. Hayabusa/Sigma artifact integration + +Files: + +- `third_party/forensics/artifacts/`; +- `scripts/import_velociraptor_artifact_pack.sh`; +- `docs/HAYABUSA_SIGMA_RULES_RU.md`. + +Tasks: + +- import/prepare `Windows.Hayabusa.Monitoring` artifact pack; +- document mapping to Hayabusa rules; +- create curated profile: + `low-cost-default`, `incident`, `full`; +- add noisy-rule tuning file; +- require version metadata in every run. + +Acceptance: + +- artifact pack import is reproducible; +- rules profile can be listed without running collection; +- config supports small-host low-resource default. + +### Phase 6. AWatch-rus result import + +Prefer Rust. + +New crate or extension: + +- `adk-rust/crates/forensics-importer`; + or extend `adk-rust/crates/hayabusa-tools`. + +Tasks: + +- import Hayabusa JSON/JSONL/CSV summary; +- import Velociraptor artifact result export; +- normalize to derived finding schema: + `source`, `host`, `time`, `rule`, `level`, `mitre`, `summary`, + `evidence_ref`, `case_id`, `tool_version`, `rules_version`; +- reject oversized, malformed and path-traversal payloads; +- write derived SQLite/JSON under `/var/lib/activitywatch/forensics`; +- do not copy raw artifacts unless `AW_FORENSICS_STORE_RAW_ARTIFACTS=true`. + +Acceptance: + +- unit tests cover malformed JSON, oversized file, path traversal, empty result; +- fixture import produces stable output; +- raw-sensitive data is not rendered in default portal view. + +### Phase 7. Containment control plane + +Prefer Rust. + +New crate or extension: + +- `adk-rust/crates/containment-engine`; + or extend `adk-rust/crates/forensics-importer` with a separate module. + +Tasks: + +- define containment decision schema and audit log; +- add policy file: + `/etc/activitywatch/containment-policy.json`; +- add host role model: + `workstation|server|domain_controller|unknown`; +- add safe defaults: + `enabled=false`, `mode=shadow`, server auto-containment disabled; +- implement decision evaluation from imported findings; +- implement dry-run/shadow output; +- implement manual approval queue; +- implement rollback record format. + +Acceptance: + +- unit tests cover workstation/server/unknown host roles; +- automatic action is refused for server/unknown role by default; +- no action runs if management channel precheck fails; +- shadow mode produces audit record and does not mutate host/network. + +### Phase 8. Containment executors + +Executor targets: + +- Windows firewall quarantine through PowerShell/Rust Windows helper; +- pfSense alias/table block through explicit API/SSH integration; +- optional switch/VLAN integration only behind feature flag. + +Tasks: + +- implement executor interface: + `plan`, `apply`, `verify`, `rollback`; +- first implemented executor: Windows Firewall explicit management allowlist + plus explicit block ranges, without broad `Any`/`LocalSubnet` block and + without default firewall profile changes; +- apply pfSense host block using IP/MAC only after current lease/identity + verification; +- store rollback before mutation; +- add TTL-based rollback timer; +- add emergency release command. + +Acceptance: + +- fixture mode shows exact firewall/pfSense plan; +- apply refuses empty allowlist; +- verify confirms blocked lateral path and allowed management path; +- rollback restores previous rules; +- logs contain no secrets. + +### Phase 9. Portal and API integration + +Files: + +- `adk-rust/crates/detmir-portal/`; +- `docs/PORTAL_API_CONTRACTS_RU.md`; +- `docs/DETMIR_CURRENT_STATE_RU.md`. + +Tasks: + +- add optional forensics module state: + `disabled|not_configured|ready|degraded`; +- show derived findings count, latest run, severity histogram; +- show containment state: + `disabled|shadow|recommended|contained|rollback_pending|released`; +- show clear action buttons only for authorized admin/security roles; +- link to case/evidence only by opaque ID; +- do not block Workforce first screen; +- do not include raw artifacts in frontend payload. + +Acceptance: + +- with module disabled, portal shows disabled-state and remains fast; +- with fixture findings, portal renders summary; +- with fixture containment recommendation, portal renders action state without + applying action; +- Playwright smoke confirms no endless loading and no raw sensitive fields. + +### Phase 10. Health/readiness/checks + +Files: + +- `adk-rust/crates/detmir-check/`; +- `adk-rust/crates/detmir-readiness/`; +- `scripts/detmir-full-diagnostics/aw-contour-diag.sh`; +- `scripts/aw-contour-diag.sh`; +- `check-aw-full.sh`. + +Tasks: + +- add optional forensics status checks; +- disabled mode must be OK/Skipped, not fail; +- add optional containment status checks; +- enabled mode checks: + Velociraptor service, artifact pack, latest run age, importer health, + queue/quarantine counts, containment executor health; +- add resource pressure checks for long-running scans. + +Acceptance: + +- disabled mode produces `forensics:mode=disabled`; +- disabled containment mode produces `containment:mode=disabled`; +- enabled mode fails closed on stale/broken artifact importer; +- containment auto mode fails closed if rollback store or admin-channel precheck + is unavailable; +- checks do not restart services unless explicit autoheal mode exists. + +### Phase 11. Runtime safety and resource budgets + +Files: + +- systemd units/timers; +- Ansible vars; +- docs runbooks. + +Tasks: + +- enforce `Nice`, `IOSchedulingClass`, CPU quota and timeout for heavy scans; +- serialize jobs through lock file; +- add cancellation/timeout behavior; +- quarantine failed artifact runs; +- keep `aw-server-rust`, worktime API, ClickHouse and portal out of the scan + critical path. +- enforce containment mutation lock so two block/unblock actions cannot race. + +Acceptance: + +- two concurrent scan requests do not run two heavy jobs; +- timeout leaves a clear failed run record; +- core health remains green under disabled mode. +- rollback timer is tested and idempotent. + +### Phase 12. Documentation and operator runbooks + +New docs: + +- `docs/VELOCIRAPTOR_DEPLOYMENT_RU.md`; +- `docs/FORENSICS_SUPPLY_CHAIN_RU.md`; +- `docs/FORENSICS_OPERATOR_RUNBOOK_RU.md`; +- `docs/FORENSICS_RETENTION_POLICY_RU.md`; +- `docs/FORENSICS_PRIVACY_GUARDRAILS_RU.md`. +- `docs/CONTAINMENT_OPERATOR_RUNBOOK_RU.md`; +- `docs/CONTAINMENT_POLICY_RU.md`. + +Tasks: + +- describe installation modes; +- describe offline collector workflow; +- describe artifact run, import, case link and cleanup; +- describe forbidden data in screenshots/demo packs; +- document rollback and disable commands. +- describe quarantine policy, management allowlist, manual approval, emergency + release and TTL rollback. + +Acceptance: + +- admin can install disabled/offline/server modes from docs; +- admin can test shadow containment safely before auto mode; +- docs clearly say GitHub/public demo is not evidence storage; +- no claim of SIEM/DLP/EDR/СЗИ replacement. + +### Phase 13. Tests and gates + +Required checks: + +```bash +python3 scripts/public_secret_pattern_check.py +bash -n scripts/prepare_forensics_binaries.sh +ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml --syntax-check +ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml --syntax-check + +cd adk-rust +export CARGO_TARGET_DIR=/home/igor/.cache/detmir-adk-rust-target +cargo fmt --all --check +cargo test -p hayabusa-tools +cargo test -p forensics-importer +cargo test -p containment-engine +cargo clippy -p hayabusa-tools -p forensics-importer -p containment-engine --all-targets -- -D warnings + +cd .. +git diff --check +``` + +Manual/live checks: + +- disabled mode on clean install; +- Hayabusa current drop-zone smoke; +- Velociraptor offline collector fixture run; +- Velociraptor server health if enabled; +- containment shadow run with fixture critical finding; +- manual approval containment in isolated lab host; +- rollback verification; +- portal browser smoke with module disabled and with fixture findings; +- no public exposure of Velociraptor GUI unless explicitly configured. + +## Codex guardrails + +Codex must not: + +- change Workforce core behavior while adding this module; +- re-enable heavy DLP runtime by accident; +- expose Velociraptor or Hayabusa outputs publicly; +- commit generated secrets, private configs or raw evidence; +- auto-block hosts before policy, allowlist, rollback and admin-channel checks + exist; +- auto-block servers/domain infrastructure by default; +- claim completed integration before live/manual evidence exists; +- change Rust/API/UI runtime outside the planned files without documenting why. + +Codex should: + +- start with docs/config disabled mode; +- implement supply-chain pinning before service deployment; +- prefer Rust for import/validation/parsing; +- treat containment as a separate audited control plane, not as generic + remediation; +- keep PowerShell only for Windows install/run wrappers; +- add small fixtures and negative tests before live deployment; +- update project status after each successfully verified phase. + +## Expected result + +After implementation AWatch-rus should have: + +- installed/pinned Hayabusa and rules workflow; +- optional bundled Velociraptor server/client/offline collector modes; +- reproducible artifact pack handling for `Windows.Hayabusa.Monitoring`; +- derived findings importer into AWatch-rus forensics views; +- policy-controlled automated/manual quarantine of suspected infected + workstations; +- rollback and emergency release path for every containment action; +- disabled-by-default safety; +- resource-bounded scans; +- clear runbooks for poor/small organizations; +- honest positioning as low-cost containment, security analytics and forensics, not + SIEM/DLP/EDR/СЗИ. diff --git a/docs/SECURITY_FINDING_INBOX_RU.md b/docs/SECURITY_FINDING_INBOX_RU.md new file mode 100644 index 0000000..1caa3cf --- /dev/null +++ b/docs/SECURITY_FINDING_INBOX_RU.md @@ -0,0 +1,279 @@ +# Security Finding Inbox + +Дата: 2026-06-25. + +Security Finding Inbox - это очередь подозрительных рабочих станций для +связки Hayabusa/Sigma, Velociraptor, AWatch context и ручных ИБ-сигналов. +Очередь нужна для контролируемого процесса: + +```text +finding -> triage -> decide -> plan -> approve -> apply_requested -> executor -> verify/rollback +``` + +Важно: портал inbox сам не применяет Windows Firewall, pfSense, AD или VLAN +изменения. Он фиксирует findings и workflow-события. Реальное применение +делается отдельным процессом `security-finding-inbox executor`, который +вызывает `containment-engine windows-firewall plan/apply/verify/rollback`. +По умолчанию executor работает безопасно: dry-run/fail-closed, без локального +изменения firewall. + +## Компоненты + +- ClickHouse schema: + `clickhouse-1c/security/security_finding_inbox.sql` +- normalized finding example: + `configs/security/security-finding.example.json` +- ingest/workflow CLI: + `adk-rust/crates/security-finding-inbox` +- executor CLI: + `security-finding-inbox executor` +- portal API: + `/api/security/findings` + `/api/security/findings/workflow` +- portal page: + `Подозрительные станции` + +## ClickHouse tables + +`security_findings` + +- normalized finding records; +- source: `hayabusa`, `sigma`, `velociraptor`, `awatch`, `manual`, `dlp`; +- states: `new`, `suspected_infected`, `confirmed_infected`, `contained`, + `released`, `false_positive`; +- recommended action remains a recommendation, not a mutation. + +`security_finding_workflow_events` + +- append-only workflow audit; +- event types: `decide_requested`, `plan_requested`, `approved`, + `apply_requested`, `verify_requested`, `rollback_requested`, `rejected`, + `false_positive`, plus executor audit events: + `executor_plan_ready`, `executor_apply_succeeded`, + `executor_apply_failed`, `executor_verify_succeeded`, + `executor_verify_failed`, `executor_refused`, + `executor_rollback_succeeded`, `executor_rollback_failed`; +- portal writes only workflow events. + +`security_finding_inbox` + +- latest-state view for portal/dashboard; +- filters released/rejected/false-positive rows out of the active queue. + +## Ingest + +Build: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +export CARGO_TARGET_DIR=/home/igor/.cache/detmir-adk-rust-target +cargo build --manifest-path adk-rust/Cargo.toml -p security-finding-inbox +``` + +Validate sample: + +```bash +security-finding-inbox validate \ + --input configs/security/security-finding.example.json +``` + +Dry-run ingest: + +```bash +security-finding-inbox ingest \ + --input configs/security/security-finding.example.json \ + --dry-run +``` + +Apply schema and ingest into ClickHouse: + +```bash +security-finding-inbox ingest \ + --input configs/security/security-finding.example.json \ + --clickhouse-url http://10.10.10.2:8123 \ + --database analytics_1c \ + --user "$CLICKHOUSE_USER" \ + --password "$CLICKHOUSE_PASSWORD" \ + --apply-schema +``` + +The CLI accepts a single JSON object, JSON array, or JSONL. + +### Real Hayabusa source + +После `aw-hayabusa process-inbox` реальный источник находится в +`/opt/hayabusa/state/latest-intake.json`. CLI читает `report_dir`, анализирует +`timeline.jsonl`, logon summaries и строит normalized finding: + +```bash +security-finding-inbox ingest-hayabusa \ + --intake /opt/hayabusa/state/latest-intake.json \ + --min-severity medium \ + --clickhouse-url http://127.0.0.1:8123 \ + --database analytics_1c +``` + +Для автоматического подключения Hayabusa drop/autoprocess: + +```bash +AW_SECURITY_FINDING_INBOX_ENABLED=true +AW_SECURITY_FINDING_INBOX_BIN=/usr/local/bin/security-finding-inbox +AW_SECURITY_FINDING_INBOX_MIN_SEVERITY=medium +``` + +`AW_SECURITY_FINDING_INBOX_REQUIRED=false` оставляет forensic pipeline живым, +если ClickHouse или inbox CLI временно недоступны. В `true`-режиме ошибка +ingest считается operational failure. + +### Real Velociraptor source + +Velociraptor artifact JSON/JSONL можно загрузить через generic adapter: + +```bash +security-finding-inbox ingest-velociraptor-json \ + --input /path/to/velociraptor-artifact.jsonl \ + --default-severity high \ + --clickhouse-url http://127.0.0.1:8123 \ + --database analytics_1c +``` + +Adapter ищет стандартные поля `Hostname`, `Artifact`, `Severity`, `Message`, +`User`, `IP`. Если формат артефакта отличается, используйте normalized +`security-finding-inbox ingest --input ...`. + +## Portal workflow + +Open: + +```text +DetMir Portal -> Подозрительные станции +``` + +The page shows: + +- host/user/IP/department; +- severity/confidence/score; +- source/rule; +- state and latest workflow status; +- recommended action; +- workflow buttons. + +Portal buttons record only workflow events: + +- `decide`: request decision calculation; +- `plan`: request containment plan; +- `approve`: operator approval record; +- `apply`: request to perform apply outside the portal; +- `rollback`: rollback request record. + +The portal does not run `containment-engine`, PowerShell, firewall commands or +network changes. + +## Executor handoff + +Executor читает из ClickHouse только те findings, где: + +- последний workflow event: `apply_requested`; +- status: `apply_pending`; +- ранее есть `approved`; +- еще нет `executor_apply_succeeded`, `executor_apply_failed`, + `executor_refused` или rollback terminal event. + +Dry-run executor: + +```bash +security-finding-inbox executor \ + --once \ + --dry-run \ + --containment-engine-bin /usr/local/bin/containment-engine \ + --policy /etc/activitywatch/containment-policy.json \ + --management-allowlist 10.10.10.10,10.10.10.11 \ + --blocked-remote-addresses 10.10.20.0/24,10.10.30.0/24 +``` + +Linux systemd example for central dry-run/polling mode: + +```text +ops/systemd/aw-security-finding-executor.service +``` + +Real local Windows apply is allowed only when all conditions are true: + +- executor runs on the target Windows workstation; +- `--execute-local` is set; +- `--confirm-execute YES` is set; +- `--executor-host` or local `COMPUTERNAME` matches finding `host`; +- containment policy returns `manual_approval_required` or `auto_ready`; +- management allowlist and blocked remote ranges are explicit; +- generated Windows Firewall plan has no blockers. + +Example on the target Windows host: + +```powershell +security-finding-inbox.exe executor ` + --once ` + --execute-local ` + --confirm-execute YES ` + --executor-host HOST-EXAMPLE ` + --containment-engine-bin C:\ProgramData\AWatch-rus\containment-engine.exe ` + --policy C:\ProgramData\AWatch-rus\containment-policy.json ` + --management-allowlist 10.10.10.10,10.10.10.11 ` + --blocked-remote-addresses 10.10.20.0/24,10.10.30.0/24 +``` + +Executor writes `executor_*` workflow events back into ClickHouse. It does not +update or delete source findings. + +## Manual containment handoff + +After a finding is approved: + +1. Build or review a containment policy/finding. +2. Run: + +```bash +containment-engine decide \ + --policy /etc/activitywatch/containment-policy.json \ + --finding /path/to/finding.json \ + --pretty +``` + +3. Build Windows Firewall request with explicit management allowlist. +4. Run: + +```bash +containment-engine windows-firewall plan \ + --request /path/to/windows-firewall-request.json \ + --pretty > /tmp/fw-plan.json +``` + +5. Confirm `blockers=[]`. +6. Dry-run: + +```bash +containment-engine windows-firewall apply \ + --plan /tmp/fw-plan.json \ + --confirm-apply YES \ + --pretty +``` + +7. Real apply only on the target Windows host: + +```powershell +containment-engine.exe windows-firewall apply ` + --plan C:\Temp\fw-plan.json ` + --confirm-apply YES ` + --execute-local ` + --pretty +``` + +## Guardrails + +- Do not place raw employee logs, secrets, passwords or customer identifiers in + findings. +- Do not treat `apply_requested` as successful containment. +- Do not run broad `Any`/`LocalSubnet` firewall blocks. +- Do not enable automatic action for servers/domain controllers. +- Keep GitHub/portal evidence separate from Russian registry release evidence. +- Keep DLP optional: Hayabusa/Velociraptor findings can continue while heavy DLP + runtime is disabled. diff --git a/docs/wiki/Hayabusa-Security-Analytics.md b/docs/wiki/Hayabusa-Security-Analytics.md index b8d77d7..609fc8a 100644 --- a/docs/wiki/Hayabusa-Security-Analytics.md +++ b/docs/wiki/Hayabusa-Security-Analytics.md @@ -31,7 +31,7 @@ journalctl -u aw-hayabusa-drop.service -n 80 --no-pager curl -fsS http://127.0.0.1:5602/api/0/dlp/cases/30 ``` -Ожидаемо: `drop` и `incoming` пустые, `latest-intake.json` имеет `status=ok`, `host=SHARKON2025`, а `LastTaskResult` Windows-задачи равен `0`. +Ожидаемо: `drop` и `incoming` пустые, `latest-intake.json` имеет `status=ok`, `host=`, а `LastTaskResult` Windows-задачи равен `0`. Для текущего DetMir production historical logical id может оставаться `SHARKON2025`, даже если физический `COMPUTERNAME` RDP-сервера изменён. ## Что получает оператор diff --git a/ops/systemd/aw-security-finding-executor.service b/ops/systemd/aw-security-finding-executor.service new file mode 100644 index 0000000..d56128a --- /dev/null +++ b/ops/systemd/aw-security-finding-executor.service @@ -0,0 +1,21 @@ +[Unit] +Description=AWatch-rus Security Finding Inbox executor +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=-/etc/activitywatch/aw-server.env +ExecStart=/usr/local/bin/security-finding-inbox executor \ + --poll-seconds 30 \ + --dry-run +Restart=on-failure +RestartSec=10 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/var/lib/activitywatch /var/lock + +[Install] +WantedBy=multi-user.target diff --git a/scripts/containment_shadow_smoke.sh b/scripts/containment_shadow_smoke.sh new file mode 100644 index 0000000..4478d19 --- /dev/null +++ b/scripts/containment_shadow_smoke.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENGINE="${CONTAINMENT_ENGINE_BIN:-}" +POLICY="${1:-$ROOT_DIR/configs/containment-policy.example.json}" +FINDING="${2:-$ROOT_DIR/configs/containment-finding.example.json}" +FIREWALL_REQUEST="${3:-$ROOT_DIR/configs/windows-firewall-containment-request.example.json}" +TMP_DIR="$(mktemp -d /tmp/containment-shadow-smoke.XXXXXX)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if [[ -z "$ENGINE" ]]; then + for candidate in \ + "${CARGO_TARGET_DIR:-}/debug/containment-engine" \ + "${CARGO_TARGET_DIR:-}/release/containment-engine" \ + "$ROOT_DIR/adk-rust/target/debug/containment-engine" \ + "$ROOT_DIR/adk-rust/target/release/containment-engine" \ + "/usr/local/bin/containment-engine"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + ENGINE="$candidate" + break + fi + done +fi + +if [[ -z "$ENGINE" ]]; then + printf 'containment-engine binary not found. Build: cargo build --manifest-path adk-rust/Cargo.toml -p containment-engine\n' >&2 + exit 2 +fi + +validate_payload() { + local expected_status="$1" + python3 -c ' +import json +import sys + +expected_status = sys.argv[1] +payload = json.load(sys.stdin) +if payload.get("would_mutate") is not False: + raise SystemExit("containment smoke failed: would_mutate must be false") +status = payload.get("decision_status") +if status != expected_status: + raise SystemExit(f"containment smoke failed: expected {expected_status!r}, got {status!r}") +print(f"containment_shadow_smoke=ok status={status}") +' "$expected_status" +} + +disabled_out="$("$ENGINE" decide --policy "$POLICY" --finding "$FINDING" --pretty)" +printf '%s\n' "$disabled_out" +validate_payload "disabled" <<<"$disabled_out" + +shadow_policy="$TMP_DIR/containment-policy-shadow.json" +python3 - "$POLICY" "$shadow_policy" <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +payload["enabled"] = True +payload["mode"] = "shadow" +json.dump(payload, open(sys.argv[2], "w", encoding="utf-8"), ensure_ascii=False, indent=2) +PY + +shadow_out="$("$ENGINE" decide --policy "$shadow_policy" --finding "$FINDING" --pretty)" +printf '%s\n' "$shadow_out" +validate_payload "shadow_recommended" <<<"$shadow_out" + +firewall_plan="$TMP_DIR/windows-firewall-plan.json" +"$ENGINE" windows-firewall plan --request "$FIREWALL_REQUEST" --pretty >"$firewall_plan" +cat "$firewall_plan" + +python3 - "$firewall_plan" <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +if payload.get("executor") != "windows_firewall": + raise SystemExit("firewall smoke failed: executor must be windows_firewall") +if payload.get("blockers"): + raise SystemExit(f"firewall smoke failed: unexpected blockers {payload['blockers']!r}") +if not payload.get("apply_commands") or not payload.get("rollback_commands"): + raise SystemExit("firewall smoke failed: apply/rollback commands must exist") +print("windows_firewall_plan_smoke=ok") +PY + +firewall_apply_out="$("$ENGINE" windows-firewall apply --plan "$firewall_plan" --confirm-apply YES --pretty)" +printf '%s\n' "$firewall_apply_out" + +python3 -c ' +import json +import sys + +payload = json.load(sys.stdin) +if payload.get("execution_status") != "dry_run_commands_ready": + raise SystemExit("firewall apply smoke failed: expected dry_run_commands_ready") +if payload.get("would_mutate") is not False: + raise SystemExit("firewall apply smoke failed: dry-run must not mutate") +print("windows_firewall_apply_dry_run_smoke=ok") +' <<<"$firewall_apply_out" diff --git a/scripts/security_finding_inbox_smoke.sh b/scripts/security_finding_inbox_smoke.sh new file mode 100644 index 0000000..60f1886 --- /dev/null +++ b/scripts/security_finding_inbox_smoke.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="${SECURITY_FINDING_INBOX_BIN:-}" +SAMPLE="${1:-$ROOT_DIR/configs/security/security-finding.example.json}" +TMP_DIR="$(mktemp -d /tmp/security-finding-inbox-smoke.XXXXXX)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if [[ -z "$BIN" ]]; then + for candidate in \ + "${CARGO_TARGET_DIR:-}/debug/security-finding-inbox" \ + "${CARGO_TARGET_DIR:-}/release/security-finding-inbox" \ + "$ROOT_DIR/adk-rust/target/debug/security-finding-inbox" \ + "$ROOT_DIR/adk-rust/target/release/security-finding-inbox" \ + "/usr/local/bin/security-finding-inbox"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + BIN="$candidate" + break + fi + done +fi + +if [[ -z "$BIN" ]]; then + printf 'security-finding-inbox binary not found. Build: cargo build --manifest-path adk-rust/Cargo.toml -p security-finding-inbox\n' >&2 + exit 2 +fi + +"$BIN" schema | grep -q 'CREATE TABLE IF NOT EXISTS analytics_1c.security_findings' +"$BIN" validate --input "$SAMPLE" | python3 -c ' +import json +import sys +payload = json.load(sys.stdin) +assert payload["ok"] is True +assert payload["rows"] == 1 +assert payload["finding_ids"][0].startswith("sf-") +print("security_finding_validate=ok") +' + +"$BIN" ingest --input "$SAMPLE" --dry-run | python3 -c ' +import json +import sys +payload = json.load(sys.stdin) +assert payload["ok"] is True +assert payload["dry_run"] is True +assert payload["rows"] == 1 +print("security_finding_ingest_dry_run=ok") +' + +mkdir -p "$TMP_DIR/hayabusa-report" +cat >"$TMP_DIR/hayabusa-report/timeline.jsonl" <<'EOF' +{"Level":"high","RuleTitle":"PowerShell Credential Dump","Timestamp":"2026-06-25T10:00:00Z"} +{"Level":"crit","RuleTitle":"Suspicious Credential Access","Timestamp":"2026-06-25T10:01:00Z"} +EOF +cat >"$TMP_DIR/hayabusa-report/logon-summary-failed.csv" <<'EOF' +header +1 +2 +EOF +cat >"$TMP_DIR/latest-intake.json" <"$TMP_DIR/velociraptor.jsonl" <<'EOF' +{"Hostname":"HOST-EXAMPLE","Artifact":"Windows.Hayabusa.Monitoring","Severity":"high","Message":"Velociraptor smoke finding","User":"user-example"} +EOF +"$BIN" ingest-velociraptor-json --input "$TMP_DIR/velociraptor.jsonl" --dry-run | python3 -c ' +import json +import sys +payload = json.load(sys.stdin) +assert payload["ok"] is True +assert payload["dry_run"] is True +assert payload["rows"] == 1 +print("security_finding_velociraptor_ingest_dry_run=ok") +' + +"$BIN" workflow \ + --finding-id sf-demo \ + --event-type approved \ + --actor smoke \ + --comment "dry-run approval" \ + --dry-run | python3 -c ' +import json +import sys +payload = json.load(sys.stdin) +assert payload["ok"] is True +assert payload["dry_run"] is True +assert payload["event_type"] == "approved" +print("security_finding_workflow_dry_run=ok") +' + +"$BIN" executor --help >/dev/null +printenv SECURITY_FINDING_INBOX_SKIP_EXECUTOR_SMOKE >/dev/null 2>&1 || \ + printf 'security_finding_executor_cli=ok\n'