feat(agent): add windows and freebsd read-only collectors

This commit is contained in:
igor04091968
2026-06-04 09:22:35 +03:00
parent 3f19efb7e3
commit 152705a999
4 changed files with 378 additions and 37 deletions
@@ -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<IdentityInfo> {
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<SessionSnapshot> {
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<Vec<ProcessInfo>> {
unsupported()
Ok(freebsd_processes(128))
}
fn collect_resources(&self) -> Result<ResourceInfo> {
unsupported()
let memory_total = command_output("sysctl", &["-n", "hw.physmem"])
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
Ok(ResourceInfo {
uptime_seconds: 0,
cpu_usage_percent: 0.0,
memory_total,
memory_used: 0,
})
}
fn collect_network(&self) -> Result<NetworkSnapshot> {
unsupported()
Ok(NetworkSnapshot {
interfaces: freebsd_interfaces(),
connections: freebsd_connections(256),
})
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
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<WorkforceActivityInfo> {
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<T>() -> Result<T> {
bail!(
"FreeBSD/pfSense collector skeleton is present; sysctl/procstat/kvm probes are planned behind the same TelemetryRecord API"
)
fn freebsd_processes(limit: usize) -> Vec<ProcessInfo> {
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::<Vec<_>>();
let pid = cols.first()?.parse::<u32>().ok()?;
let ppid = cols.get(1).and_then(|value| value.parse::<u32>().ok());
let name = cols.get(2).unwrap_or(&"process").to_string();
let memory_bytes = cols
.get(3)
.and_then(|value| value.parse::<u64>().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::<Vec<_>>();
items.truncate(limit);
items
}
fn freebsd_interfaces() -> Vec<NetworkInterfaceInfo> {
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<NetworkConnectionInfo> {
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::<Vec<_>>();
items.truncate(limit);
items
}
fn parse_sockstat_line(line: &str) -> Option<NetworkConnectionInfo> {
let cols = line.split_whitespace().collect::<Vec<_>>();
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::<u32>().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<String> {
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}"
))
}
@@ -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<IdentityInfo> {
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<SessionSnapshot> {
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<Vec<ProcessInfo>> {
unsupported()
Ok(windows_processes(128))
}
fn collect_resources(&self) -> Result<ResourceInfo> {
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<NetworkSnapshot> {
unsupported()
Ok(NetworkSnapshot {
interfaces: Vec::new(),
connections: windows_connections(256),
})
}
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
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<WorkforceActivityInfo> {
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<T>() -> Result<T> {
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::<u64>().unwrap_or(0);
}
if let Some(value) = line.strip_prefix("TotalVisibleMemorySize=") {
total_kib = value.trim().parse::<u64>().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<ProcessInfo> {
let Some(raw) = command_output("tasklist", &["/FO", "CSV", "/NH"]) else {
return Vec::new();
};
let mut items = raw
.lines()
.filter_map(parse_tasklist_line)
.collect::<Vec<_>>();
items.truncate(limit);
items
}
fn parse_tasklist_line(line: &str) -> Option<ProcessInfo> {
let cols = parse_csv_line(line);
let name = cols.first()?.to_string();
let pid = cols.get(1)?.parse::<u32>().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<String> {
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::<String>()
.parse::<u64>()
.unwrap_or(0)
.saturating_mul(1024)
}
fn windows_connections(limit: usize) -> Vec<NetworkConnectionInfo> {
let Some(raw) = command_output("netstat", &["-ano"]) else {
return Vec::new();
};
let mut items = raw
.lines()
.filter_map(parse_netstat_line)
.collect::<Vec<_>>();
items.truncate(limit);
items
}
fn parse_netstat_line(line: &str) -> Option<NetworkConnectionInfo> {
let cols = line.split_whitespace().collect::<Vec<_>>();
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::<u32>().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()?,
))
}
+2 -2
View File
@@ -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-анализа.
+11 -5
View File
@@ -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