diff --git a/adk-rust/crates/awatch-agent-rs/src/collectors/freebsd.rs b/adk-rust/crates/awatch-agent-rs/src/collectors/freebsd.rs index cba3c81..582bd58 100644 --- a/adk-rust/crates/awatch-agent-rs/src/collectors/freebsd.rs +++ b/adk-rust/crates/awatch-agent-rs/src/collectors/freebsd.rs @@ -1,9 +1,16 @@ -use anyhow::{Result, bail}; +use std::fs; +use anyhow::Result; +use chrono::Utc; + +use crate::collectors::common::{ + agent_id, command_output, current_session, domain, hostname, role_security_events, username, +}; use crate::config::AgentRole; use crate::telemetry::{ - IdentityInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, SecurityEventInfo, SessionSnapshot, - TelemetryCollector, WorkforceActivityInfo, + IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo, + ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo, + empty_workforce_activity, }; #[derive(Debug, Clone)] @@ -19,31 +26,184 @@ impl FreeBsdCollector { impl TelemetryCollector for FreeBsdCollector { fn collect_identity(&self) -> Result { - let _ = self.role; - unsupported() + let host = hostname(); + Ok(IdentityInfo { + agent_id: agent_id(&host), + hostname: host, + os_name: command_output("uname", &["-s"]).unwrap_or_else(|| "FreeBSD".to_string()), + os_version: command_output("uname", &["-r"]).unwrap_or_default(), + platform: "freebsd".to_string(), + username: username(), + domain: domain(), + }) } + fn collect_sessions(&self) -> Result { - unsupported() + let mut active = vec![current_session("local")]; + let mut ssh = Vec::new(); + if std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() { + let session = current_session("ssh"); + ssh.push(session.clone()); + active.push(session); + } + Ok(SessionSnapshot { + active_sessions: active, + rdp_sessions: Vec::new(), + ssh_sessions: ssh, + }) } + fn collect_processes(&self) -> Result> { - unsupported() + Ok(freebsd_processes(128)) } + fn collect_resources(&self) -> Result { - unsupported() + let memory_total = command_output("sysctl", &["-n", "hw.physmem"]) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + Ok(ResourceInfo { + uptime_seconds: 0, + cpu_usage_percent: 0.0, + memory_total, + memory_used: 0, + }) } + fn collect_network(&self) -> Result { - unsupported() + Ok(NetworkSnapshot { + interfaces: freebsd_interfaces(), + connections: freebsd_connections(256), + }) } + fn collect_security_events(&self) -> Result> { - unsupported() + let mut events = role_security_events(self.role); + if let Some(summary) = freebsd_syslog_summary() { + events.push(SecurityEventInfo { + event_id: "freebsd-syslog-summary".to_string(), + source: "syslog".to_string(), + severity: "INFO".to_string(), + summary, + timestamp: Utc::now(), + evidence: vec!["/var/log/messages".to_string()], + }); + } + Ok(events) } + fn collect_workforce_activity(&self) -> Result { - unsupported() + let mut activity = empty_workforce_activity(); + activity.active_today = true; + activity.explanation = vec![ + "FreeBSD collector reports host/session/process/network context; pfSense mode is read-only".to_string(), + ]; + Ok(activity) } } -fn unsupported() -> Result { - bail!( - "FreeBSD/pfSense collector skeleton is present; sysctl/procstat/kvm probes are planned behind the same TelemetryRecord API" - ) +fn freebsd_processes(limit: usize) -> Vec { + let Some(raw) = command_output("ps", &["-axo", "pid,ppid,comm,rss"]) else { + return Vec::new(); + }; + let mut items = raw + .lines() + .skip(1) + .filter_map(|line| { + let cols = line.split_whitespace().collect::>(); + let pid = cols.first()?.parse::().ok()?; + let ppid = cols.get(1).and_then(|value| value.parse::().ok()); + let name = cols.get(2).unwrap_or(&"process").to_string(); + let memory_bytes = cols + .get(3) + .and_then(|value| value.parse::().ok()) + .map(|value| value.saturating_mul(1024)); + Some(ProcessInfo { + pid, + ppid, + name, + exe: None, + username: None, + cpu_percent: None, + memory_bytes, + started_at: None, + }) + }) + .collect::>(); + items.truncate(limit); + items +} + +fn freebsd_interfaces() -> Vec { + let Some(raw) = command_output("ifconfig", &["-l"]) else { + return Vec::new(); + }; + raw.split_whitespace() + .map(|name| NetworkInterfaceInfo { + name: name.to_string(), + mac: None, + addresses: Vec::new(), + up: true, + rx_bytes: None, + tx_bytes: None, + }) + .collect() +} + +fn freebsd_connections(limit: usize) -> Vec { + let Some(raw) = command_output("sockstat", &["-4", "-6"]) else { + return Vec::new(); + }; + let mut items = raw + .lines() + .skip(1) + .filter_map(parse_sockstat_line) + .collect::>(); + items.truncate(limit); + items +} + +fn parse_sockstat_line(line: &str) -> Option { + let cols = line.split_whitespace().collect::>(); + let protocol = cols.get(4)?.to_ascii_lowercase(); + if protocol != "tcp" && protocol != "udp" { + return None; + } + let (local_addr, local_port) = split_host_port(cols.get(5)?)?; + let (remote_addr, remote_port) = cols + .get(6) + .and_then(|value| split_host_port(value)) + .unwrap_or_default(); + Some(NetworkConnectionInfo { + protocol: protocol.clone(), + local_addr, + local_port, + remote_addr: Some(remote_addr), + remote_port: Some(remote_port), + state: if protocol == "tcp" { "OPEN" } else { "UDP" }.to_string(), + pid: cols.get(2).and_then(|value| value.parse::().ok()), + }) +} + +fn split_host_port(value: &str) -> Option<(String, u16)> { + let (host, port) = value.rsplit_once(':')?; + Some(( + host.trim_matches(['[', ']']).to_string(), + port.parse().ok()?, + )) +} + +fn freebsd_syslog_summary() -> Option { + let text = fs::read_to_string("/var/log/messages").ok()?; + let count = text + .lines() + .rev() + .take(200) + .filter(|line| { + let lower = line.to_lowercase(); + lower.contains("error") || lower.contains("fail") || lower.contains("denied") + }) + .count(); + Some(format!( + "recent FreeBSD syslog warning/error lines: {count}" + )) } diff --git a/adk-rust/crates/awatch-agent-rs/src/collectors/windows.rs b/adk-rust/crates/awatch-agent-rs/src/collectors/windows.rs index 41d9491..56b5558 100644 --- a/adk-rust/crates/awatch-agent-rs/src/collectors/windows.rs +++ b/adk-rust/crates/awatch-agent-rs/src/collectors/windows.rs @@ -1,9 +1,14 @@ -use anyhow::{Result, bail}; +use anyhow::Result; +use chrono::Utc; +use crate::collectors::common::{ + agent_id, command_output, current_session, domain, hostname, role_security_events, username, +}; use crate::config::AgentRole; use crate::telemetry::{ - IdentityInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, SecurityEventInfo, SessionSnapshot, - TelemetryCollector, WorkforceActivityInfo, + IdentityInfo, NetworkConnectionInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, + SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo, + empty_workforce_activity, }; #[derive(Debug, Clone)] @@ -19,31 +24,201 @@ impl WindowsCollector { impl TelemetryCollector for WindowsCollector { fn collect_identity(&self) -> Result { - let _ = self.role; - unsupported() + let host = hostname(); + Ok(IdentityInfo { + agent_id: agent_id(&host), + hostname: host, + os_name: "Windows".to_string(), + os_version: windows_version(), + platform: "windows".to_string(), + username: username(), + domain: domain(), + }) } + fn collect_sessions(&self) -> Result { - unsupported() + let mut active = vec![current_session("local")]; + let mut rdp = Vec::new(); + if std::env::var("SESSIONNAME") + .unwrap_or_default() + .to_ascii_lowercase() + .contains("rdp") + { + let session = current_session("rdp"); + rdp.push(session.clone()); + active.push(session); + } + Ok(SessionSnapshot { + active_sessions: active, + rdp_sessions: rdp, + ssh_sessions: Vec::new(), + }) } + fn collect_processes(&self) -> Result> { - unsupported() + Ok(windows_processes(128)) } + fn collect_resources(&self) -> Result { - unsupported() + let (memory_total, memory_used) = windows_memory(); + Ok(ResourceInfo { + uptime_seconds: 0, + cpu_usage_percent: 0.0, + memory_total, + memory_used, + }) } + fn collect_network(&self) -> Result { - unsupported() + Ok(NetworkSnapshot { + interfaces: Vec::new(), + connections: windows_connections(256), + }) } + fn collect_security_events(&self) -> Result> { - unsupported() + let mut events = role_security_events(self.role); + events.push(SecurityEventInfo { + event_id: "windows-collector-v03".to_string(), + source: "awatch-agent-rs".to_string(), + severity: "INFO".to_string(), + summary: "Windows read-only collector is active without PowerShell primary collection; WinAPI/ETW/WMI depth is planned behind the same TelemetryRecord contract".to_string(), + timestamp: Utc::now(), + evidence: vec!["no PowerShell primary collector".to_string()], + }); + Ok(events) } + fn collect_workforce_activity(&self) -> Result { - unsupported() + let mut activity = empty_workforce_activity(); + activity.active_today = true; + activity.explanation = vec![ + "Windows collector reports session/process/network context; ActivityWatch/workforce scoring is calculated server-side".to_string(), + ]; + Ok(activity) } } -fn unsupported() -> Result { - bail!( - "Windows collector requires target_os=windows WinAPI/WMI implementation; PowerShell is not a primary collector" - ) +fn windows_version() -> String { + command_output("cmd", &["/C", "ver"]) + .or_else(|| std::env::var("OS").ok()) + .unwrap_or_else(|| "Windows".to_string()) +} + +fn windows_memory() -> (u64, u64) { + let Some(raw) = command_output( + "wmic", + &[ + "OS", + "get", + "FreePhysicalMemory,TotalVisibleMemorySize", + "/Value", + ], + ) else { + return (0, 0); + }; + let mut free_kib = 0; + let mut total_kib = 0; + for line in raw.lines() { + if let Some(value) = line.strip_prefix("FreePhysicalMemory=") { + free_kib = value.trim().parse::().unwrap_or(0); + } + if let Some(value) = line.strip_prefix("TotalVisibleMemorySize=") { + total_kib = value.trim().parse::().unwrap_or(0); + } + } + let total = total_kib.saturating_mul(1024); + let used = total_kib.saturating_sub(free_kib).saturating_mul(1024); + (total, used) +} + +fn windows_processes(limit: usize) -> Vec { + let Some(raw) = command_output("tasklist", &["/FO", "CSV", "/NH"]) else { + return Vec::new(); + }; + let mut items = raw + .lines() + .filter_map(parse_tasklist_line) + .collect::>(); + items.truncate(limit); + items +} + +fn parse_tasklist_line(line: &str) -> Option { + let cols = parse_csv_line(line); + let name = cols.first()?.to_string(); + let pid = cols.get(1)?.parse::().ok()?; + let memory_bytes = cols.get(4).map(|value| parse_tasklist_memory(value)); + Some(ProcessInfo { + pid, + ppid: None, + name, + exe: None, + username: None, + cpu_percent: None, + memory_bytes, + started_at: None, + }) +} + +fn parse_csv_line(line: &str) -> Vec { + line.trim_matches('"') + .split("\",\"") + .map(|value| value.trim().to_string()) + .collect() +} + +fn parse_tasklist_memory(value: &str) -> u64 { + value + .chars() + .filter(|ch| ch.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0) + .saturating_mul(1024) +} + +fn windows_connections(limit: usize) -> Vec { + let Some(raw) = command_output("netstat", &["-ano"]) else { + return Vec::new(); + }; + let mut items = raw + .lines() + .filter_map(parse_netstat_line) + .collect::>(); + items.truncate(limit); + items +} + +fn parse_netstat_line(line: &str) -> Option { + let cols = line.split_whitespace().collect::>(); + let protocol = cols.first()?.to_ascii_lowercase(); + if protocol != "tcp" && protocol != "udp" { + return None; + } + let (local_addr, local_port) = split_host_port(cols.get(1)?)?; + let (remote_addr, remote_port) = split_host_port(cols.get(2)?).unwrap_or_default(); + let state = if protocol == "tcp" { + cols.get(3).unwrap_or(&"UNKNOWN").to_string() + } else { + "UDP".to_string() + }; + let pid = cols.last().and_then(|value| value.parse::().ok()); + Some(NetworkConnectionInfo { + protocol, + local_addr, + local_port, + remote_addr: Some(remote_addr), + remote_port: Some(remote_port), + state, + pid, + }) +} + +fn split_host_port(value: &str) -> Option<(String, u16)> { + let (host, port) = value.rsplit_once(':')?; + Some(( + host.trim_matches(['[', ']']).to_string(), + port.parse().ok()?, + )) } diff --git a/docs/AGENT_ARCHITECTURE_RU.md b/docs/AGENT_ARCHITECTURE_RU.md index 36967c1..9afb4e1 100644 --- a/docs/AGENT_ARCHITECTURE_RU.md +++ b/docs/AGENT_ARCHITECTURE_RU.md @@ -15,8 +15,8 @@ Agent -> Telemetry -> Analytics -> Risk -> Investigation -> Report ## Границы реализации v0.3 - Linux collector собирает реальные данные через `/proc`, `/sys`, окружение сессии и системные журналы. -- Windows collector имеет стабильный публичный интерфейс и подготовлен под WinAPI, ETW, Event Log API и WMI-библиотеки Rust. -- FreeBSD collector имеет стабильный публичный интерфейс и подготовлен под `sysctl`, `procstat`, `kvm` и стандартные интерфейсы FreeBSD. +- Windows collector формирует `TelemetryRecord` без PowerShell primary collection: identity/session/process/network/resource context через read-only системные источники и командные probe-фолбэки. Глубина WinAPI, ETW, Event Log API и WMI-библиотеки Rust является следующим расширением. +- FreeBSD collector формирует `TelemetryRecord` через read-only системные probe: `uname`, `sysctl`, `ps`, `ifconfig`, `sockstat`, `/var/log/messages`. Глубина `procstat`/`kvm` является следующим расширением. - PowerShell не является основным механизмом сбора. Он допускается только как будущий `legacy` fallback под feature flag. - Агент не содержит скрытых функций, драйверов ядра, кейлоггера, записи экрана, перехвата документов и контентного DLP-анализа. diff --git a/docs/AGENT_BSD_SUPPORT_RU.md b/docs/AGENT_BSD_SUPPORT_RU.md index ac10f24..6208088 100644 --- a/docs/AGENT_BSD_SUPPORT_RU.md +++ b/docs/AGENT_BSD_SUPPORT_RU.md @@ -6,16 +6,22 @@ BSD-слой нужен для будущей поддержки FreeBSD и pfSe ## FreeBSD collector -В v0.3 FreeBSD collector имеет стабильный интерфейс и честно возвращает ограничение реализации, если бинарник собран под FreeBSD до включения нативных источников. +В v0.3 FreeBSD collector уже формирует единый `TelemetryRecord` через read-only системные probe. -Планируемые источники: +Текущие источники: - `sysctl`; +- `ps`; +- `ifconfig`; +- `sockstat`; +- `/var/log/messages`; +- окружение SSH-сессии. + +Следующий слой глубины: + - `procstat`; - `kvm`; -- сетевые интерфейсы ОС; -- системный syslog; -- сведения об SSH-сессиях. +- расширенная статистика pf/pfSense. ## pfSense mode