feat(agent): add windows and freebsd read-only collectors
This commit is contained in:
@@ -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()?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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-анализа.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user