feat(agent): add rust telemetry agent v0.3
This commit is contained in:
Generated
+13
@@ -283,6 +283,19 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "awatch-agent-rs"
|
||||||
|
version = "0.3.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
|
"crates/awatch-agent-rs",
|
||||||
"crates/detmir-auto",
|
"crates/detmir-auto",
|
||||||
"crates/detmir-aw-client",
|
"crates/detmir-aw-client",
|
||||||
"crates/aw-db-health",
|
"crates/aw-db-health",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "awatch-agent-rs"
|
||||||
|
version = "0.3.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
legacy-powershell = []
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
server_url = "https://awatch.local/api/telemetry"
|
||||||
|
api_key = "change-me"
|
||||||
|
collect_interval_seconds = 60
|
||||||
|
role = "workstation"
|
||||||
|
|
||||||
|
enable_processes = true
|
||||||
|
enable_network = true
|
||||||
|
enable_security_events = true
|
||||||
|
enable_workforce_activity = true
|
||||||
|
|
||||||
|
spool_dir = "/var/lib/awatch-agent/spool"
|
||||||
|
timeout_seconds = 10
|
||||||
|
retry_attempts = 3
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::config::AgentRole;
|
||||||
|
use crate::telemetry::{SecurityEventInfo, SessionInfo};
|
||||||
|
|
||||||
|
pub fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new(program).args(args).output().ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hostname() -> String {
|
||||||
|
env::var("HOSTNAME")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| fs::read_to_string("/etc/hostname").ok())
|
||||||
|
.or_else(|| command_output("hostname", &[]))
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "HOST-EXAMPLE".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn username() -> String {
|
||||||
|
env::var("USER")
|
||||||
|
.or_else(|_| env::var("USERNAME"))
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn domain() -> String {
|
||||||
|
env::var("USERDOMAIN")
|
||||||
|
.or_else(|_| env::var("DOMAIN"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agent_id(hostname: &str) -> String {
|
||||||
|
format!("awatch-{hostname}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn role_security_events(role: AgentRole) -> Vec<SecurityEventInfo> {
|
||||||
|
if role == AgentRole::Firewall {
|
||||||
|
vec![SecurityEventInfo {
|
||||||
|
event_id: "pfsense-mode-prototype".to_string(),
|
||||||
|
source: "awatch-agent-rs".to_string(),
|
||||||
|
severity: "INFO".to_string(),
|
||||||
|
summary: "pfSense/firewall mode enabled; counters are collected from platform-specific probes when available".to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
evidence: vec!["read-only mode".to_string()],
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_session(session_type: &str) -> SessionInfo {
|
||||||
|
SessionInfo {
|
||||||
|
session_id: format!("{}-{}", session_type, username()),
|
||||||
|
username: username(),
|
||||||
|
session_type: session_type.to_string(),
|
||||||
|
remote_addr: std::env::var("SSH_CLIENT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.split_whitespace().next().map(str::to_string)),
|
||||||
|
started_at: None,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_os_release(path: &Path) -> (String, String) {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_default();
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut version = String::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(value) = line.strip_prefix("NAME=") {
|
||||||
|
name = value.trim_matches('"').to_string();
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("VERSION_ID=") {
|
||||||
|
version = value.trim_matches('"').to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name.is_empty() {
|
||||||
|
name = "Linux".to_string();
|
||||||
|
}
|
||||||
|
(name, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_hex_ipv4(value: &str) -> Option<String> {
|
||||||
|
if value.len() != 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw = u32::from_str_radix(value, 16).ok()?;
|
||||||
|
let bytes = raw.to_le_bytes();
|
||||||
|
Some(format!(
|
||||||
|
"{}.{}.{}.{}",
|
||||||
|
bytes[0], bytes[1], bytes[2], bytes[3]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tcp_state(value: &str) -> &'static str {
|
||||||
|
match value {
|
||||||
|
"01" => "ESTABLISHED",
|
||||||
|
"02" => "SYN_SENT",
|
||||||
|
"03" => "SYN_RECV",
|
||||||
|
"04" => "FIN_WAIT1",
|
||||||
|
"05" => "FIN_WAIT2",
|
||||||
|
"06" => "TIME_WAIT",
|
||||||
|
"07" => "CLOSE",
|
||||||
|
"08" => "CLOSE_WAIT",
|
||||||
|
"09" => "LAST_ACK",
|
||||||
|
"0A" => "LISTEN",
|
||||||
|
"0B" => "CLOSING",
|
||||||
|
_ => "UNKNOWN",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
|
use crate::config::AgentRole;
|
||||||
|
use crate::telemetry::{
|
||||||
|
IdentityInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, SecurityEventInfo, SessionSnapshot,
|
||||||
|
TelemetryCollector, WorkforceActivityInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FreeBsdCollector {
|
||||||
|
role: AgentRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FreeBsdCollector {
|
||||||
|
pub fn new(role: AgentRole) -> Self {
|
||||||
|
Self { role }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelemetryCollector for FreeBsdCollector {
|
||||||
|
fn collect_identity(&self) -> Result<IdentityInfo> {
|
||||||
|
let _ = self.role;
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_sessions(&self) -> Result<SessionSnapshot> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_resources(&self) -> Result<ResourceInfo> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_network(&self) -> Result<NetworkSnapshot> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported<T>() -> Result<T> {
|
||||||
|
bail!(
|
||||||
|
"FreeBSD/pfSense collector skeleton is present; sysctl/procstat/kvm probes are planned behind the same TelemetryRecord API"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::collectors::common::{
|
||||||
|
agent_id, current_session, domain, hostname, parse_hex_ipv4, parse_os_release, read_trimmed,
|
||||||
|
role_security_events, tcp_state, username,
|
||||||
|
};
|
||||||
|
use crate::config::AgentRole;
|
||||||
|
use crate::telemetry::{
|
||||||
|
IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo,
|
||||||
|
ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo,
|
||||||
|
empty_workforce_activity,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LinuxCollector {
|
||||||
|
role: AgentRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxCollector {
|
||||||
|
pub fn new(role: AgentRole) -> Self {
|
||||||
|
Self { role }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelemetryCollector for LinuxCollector {
|
||||||
|
fn collect_identity(&self) -> Result<IdentityInfo> {
|
||||||
|
let host = hostname();
|
||||||
|
let (os_name, os_version) = parse_os_release(Path::new("/etc/os-release"));
|
||||||
|
Ok(IdentityInfo {
|
||||||
|
agent_id: agent_id(&host),
|
||||||
|
hostname: host,
|
||||||
|
os_name,
|
||||||
|
os_version,
|
||||||
|
platform: "linux".to_string(),
|
||||||
|
username: username(),
|
||||||
|
domain: domain(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_sessions(&self) -> Result<SessionSnapshot> {
|
||||||
|
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>> {
|
||||||
|
Ok(read_processes(128))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_resources(&self) -> Result<ResourceInfo> {
|
||||||
|
let uptime_seconds = fs::read_to_string("/proc/uptime")
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
|
||||||
|
.map(|value| value as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let (memory_total, memory_available) = read_meminfo();
|
||||||
|
Ok(ResourceInfo {
|
||||||
|
uptime_seconds,
|
||||||
|
cpu_usage_percent: read_loadavg_percent(),
|
||||||
|
memory_total,
|
||||||
|
memory_used: memory_total.saturating_sub(memory_available),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_network(&self) -> Result<NetworkSnapshot> {
|
||||||
|
Ok(NetworkSnapshot {
|
||||||
|
interfaces: read_interfaces(),
|
||||||
|
connections: read_connections(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
|
||||||
|
let mut events = role_security_events(self.role);
|
||||||
|
if let Some(summary) = recent_syslog_summary() {
|
||||||
|
events.push(SecurityEventInfo {
|
||||||
|
event_id: "linux-syslog-summary".to_string(),
|
||||||
|
source: "syslog".to_string(),
|
||||||
|
severity: "INFO".to_string(),
|
||||||
|
summary,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
evidence: vec!["/var/log/syslog or /var/log/messages".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
|
||||||
|
let mut activity = empty_workforce_activity();
|
||||||
|
activity.active_today = true;
|
||||||
|
activity.explanation = vec![
|
||||||
|
"Linux collector reports presence and process/network context; application weighting is calculated server-side".to_string(),
|
||||||
|
];
|
||||||
|
Ok(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_meminfo() -> (u64, u64) {
|
||||||
|
let mut total = 0;
|
||||||
|
let mut available = 0;
|
||||||
|
let text = fs::read_to_string("/proc/meminfo").unwrap_or_default();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(value) = line.strip_prefix("MemTotal:") {
|
||||||
|
total = parse_kib(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = line.strip_prefix("MemAvailable:") {
|
||||||
|
available = parse_kib(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(total, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_kib(value: &str) -> u64 {
|
||||||
|
value
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.and_then(|item| item.parse::<u64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
* 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_loadavg_percent() -> f64 {
|
||||||
|
fs::read_to_string("/proc/loadavg")
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
|
||||||
|
.map(|load| (load * 100.0).clamp(0.0, 100.0))
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_processes(limit: usize) -> Vec<ProcessInfo> {
|
||||||
|
let mut items = fs::read_dir("/proc")
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let pid = entry.file_name().to_string_lossy().parse::<u32>().ok()?;
|
||||||
|
let stat = fs::read_to_string(entry.path().join("stat")).ok()?;
|
||||||
|
let name = stat.split_once('(')?.1.split_once(')')?.0.to_string();
|
||||||
|
let exe = fs::read_link(entry.path().join("exe"))
|
||||||
|
.ok()
|
||||||
|
.map(|path| path.display().to_string());
|
||||||
|
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
|
||||||
|
let ppid = status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("PPid:"))
|
||||||
|
.and_then(|value| value.trim().parse::<u32>().ok());
|
||||||
|
let memory_bytes = status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmRSS:"))
|
||||||
|
.map(parse_kib);
|
||||||
|
Some(ProcessInfo {
|
||||||
|
pid,
|
||||||
|
ppid,
|
||||||
|
name,
|
||||||
|
exe,
|
||||||
|
username: None,
|
||||||
|
cpu_percent: None,
|
||||||
|
memory_bytes,
|
||||||
|
started_at: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
items.sort_by_key(|item| item.pid);
|
||||||
|
items.truncate(limit);
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_interfaces() -> Vec<NetworkInterfaceInfo> {
|
||||||
|
fs::read_dir("/sys/class/net")
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||||
|
.map(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let up = read_trimmed(path.join("operstate")).is_some_and(|state| state == "up");
|
||||||
|
let rx_bytes =
|
||||||
|
read_trimmed(path.join("statistics/rx_bytes")).and_then(|v| v.parse().ok());
|
||||||
|
let tx_bytes =
|
||||||
|
read_trimmed(path.join("statistics/tx_bytes")).and_then(|v| v.parse().ok());
|
||||||
|
NetworkInterfaceInfo {
|
||||||
|
name,
|
||||||
|
mac: read_trimmed(path.join("address")),
|
||||||
|
addresses: Vec::new(),
|
||||||
|
up,
|
||||||
|
rx_bytes,
|
||||||
|
tx_bytes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_connections() -> Vec<NetworkConnectionInfo> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
read_proc_net("/proc/net/tcp", "tcp", &mut items);
|
||||||
|
read_proc_net("/proc/net/udp", "udp", &mut items);
|
||||||
|
items.truncate(256);
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_proc_net(path: &str, protocol: &str, items: &mut Vec<NetworkConnectionInfo>) {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_default();
|
||||||
|
for line in text.lines().skip(1) {
|
||||||
|
let cols = line.split_whitespace().collect::<Vec<_>>();
|
||||||
|
if cols.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((local_addr, local_port)) = parse_addr(cols[1]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (remote_addr, remote_port) = parse_addr(cols[2]).unwrap_or_default();
|
||||||
|
items.push(NetworkConnectionInfo {
|
||||||
|
protocol: protocol.to_string(),
|
||||||
|
local_addr,
|
||||||
|
local_port,
|
||||||
|
remote_addr: if remote_addr == "0.0.0.0" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(remote_addr)
|
||||||
|
},
|
||||||
|
remote_port: if remote_port == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(remote_port)
|
||||||
|
},
|
||||||
|
state: if protocol == "tcp" {
|
||||||
|
tcp_state(cols[3]).to_string()
|
||||||
|
} else {
|
||||||
|
"UDP".to_string()
|
||||||
|
},
|
||||||
|
pid: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_addr(value: &str) -> Option<(String, u16)> {
|
||||||
|
let (ip, port) = value.split_once(':')?;
|
||||||
|
Some((parse_hex_ipv4(ip)?, u16::from_str_radix(port, 16).ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recent_syslog_summary() -> Option<String> {
|
||||||
|
for path in ["/var/log/syslog", "/var/log/messages"] {
|
||||||
|
let Ok(text) = fs::read_to_string(path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let count = text
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.take(200)
|
||||||
|
.filter(|line| {
|
||||||
|
let lower = line.to_lowercase();
|
||||||
|
lower.contains("error") || lower.contains("fail") || lower.contains("denied")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
return Some(format!("recent syslog warning/error lines: {count}"));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
pub mod common;
|
||||||
|
pub mod freebsd;
|
||||||
|
pub mod linux;
|
||||||
|
pub mod windows;
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
|
use crate::config::AgentRole;
|
||||||
|
use crate::telemetry::TelemetryCollector;
|
||||||
|
|
||||||
|
pub fn platform_collector(role: AgentRole) -> Result<Box<dyn TelemetryCollector>> {
|
||||||
|
if cfg!(target_os = "linux") {
|
||||||
|
return Ok(Box::new(linux::LinuxCollector::new(role)));
|
||||||
|
}
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
return Ok(Box::new(windows::WindowsCollector::new(role)));
|
||||||
|
}
|
||||||
|
if cfg!(target_os = "freebsd") {
|
||||||
|
return Ok(Box::new(freebsd::FreeBsdCollector::new(role)));
|
||||||
|
}
|
||||||
|
bail!("unsupported platform for awatch-agent-rs")
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
|
use crate::config::AgentRole;
|
||||||
|
use crate::telemetry::{
|
||||||
|
IdentityInfo, NetworkSnapshot, ProcessInfo, ResourceInfo, SecurityEventInfo, SessionSnapshot,
|
||||||
|
TelemetryCollector, WorkforceActivityInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WindowsCollector {
|
||||||
|
role: AgentRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsCollector {
|
||||||
|
pub fn new(role: AgentRole) -> Self {
|
||||||
|
Self { role }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelemetryCollector for WindowsCollector {
|
||||||
|
fn collect_identity(&self) -> Result<IdentityInfo> {
|
||||||
|
let _ = self.role;
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_sessions(&self) -> Result<SessionSnapshot> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_resources(&self) -> Result<ResourceInfo> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_network(&self) -> Result<NetworkSnapshot> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
|
||||||
|
unsupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported<T>() -> Result<T> {
|
||||||
|
bail!(
|
||||||
|
"Windows collector requires target_os=windows WinAPI/WMI implementation; PowerShell is not a primary collector"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct AgentConfig {
|
||||||
|
pub server_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub collect_interval_seconds: u64,
|
||||||
|
pub role: AgentRole,
|
||||||
|
pub enable_processes: bool,
|
||||||
|
pub enable_network: bool,
|
||||||
|
pub enable_security_events: bool,
|
||||||
|
pub enable_workforce_activity: bool,
|
||||||
|
pub spool_dir: PathBuf,
|
||||||
|
pub timeout_seconds: u64,
|
||||||
|
pub retry_attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AgentRole {
|
||||||
|
Workstation,
|
||||||
|
Server,
|
||||||
|
Firewall,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentRole {
|
||||||
|
pub fn parse(value: &str) -> Self {
|
||||||
|
match value.trim().to_lowercase().as_str() {
|
||||||
|
"firewall" | "pfsense" => Self::Firewall,
|
||||||
|
"server" => Self::Server,
|
||||||
|
_ => Self::Workstation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AgentConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
server_url: "https://awatch.local/api/telemetry".to_string(),
|
||||||
|
api_key: "change-me".to_string(),
|
||||||
|
collect_interval_seconds: 60,
|
||||||
|
role: AgentRole::Workstation,
|
||||||
|
enable_processes: true,
|
||||||
|
enable_network: true,
|
||||||
|
enable_security_events: true,
|
||||||
|
enable_workforce_activity: true,
|
||||||
|
spool_dir: default_spool_dir(),
|
||||||
|
timeout_seconds: 10,
|
||||||
|
retry_attempts: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config_path() -> PathBuf {
|
||||||
|
if cfg!(windows) {
|
||||||
|
PathBuf::from(r"C:\ProgramData\AWatch\agent\awatch-agent.toml")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("/etc/awatch-agent/awatch-agent.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_spool_dir() -> PathBuf {
|
||||||
|
if cfg!(windows) {
|
||||||
|
PathBuf::from(r"C:\ProgramData\AWatch\agent\spool")
|
||||||
|
} else {
|
||||||
|
PathBuf::from("/var/lib/awatch-agent/spool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentConfig {
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
Self::parse_toml_like(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_toml_like(text: &str) -> Result<Self> {
|
||||||
|
let mut config = Self::default();
|
||||||
|
for raw in text.lines() {
|
||||||
|
let line = raw.split('#').next().unwrap_or("").trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim().trim_matches('"');
|
||||||
|
match key {
|
||||||
|
"server_url" => config.server_url = value.to_string(),
|
||||||
|
"api_key" => config.api_key = value.to_string(),
|
||||||
|
"collect_interval_seconds" => {
|
||||||
|
config.collect_interval_seconds = value.parse().unwrap_or(60)
|
||||||
|
}
|
||||||
|
"role" => config.role = AgentRole::parse(value),
|
||||||
|
"enable_processes" => config.enable_processes = parse_bool(value, true),
|
||||||
|
"enable_network" => config.enable_network = parse_bool(value, true),
|
||||||
|
"enable_security_events" => config.enable_security_events = parse_bool(value, true),
|
||||||
|
"enable_workforce_activity" => {
|
||||||
|
config.enable_workforce_activity = parse_bool(value, true)
|
||||||
|
}
|
||||||
|
"spool_dir" => config.spool_dir = PathBuf::from(value),
|
||||||
|
"timeout_seconds" => config.timeout_seconds = value.parse().unwrap_or(10),
|
||||||
|
"retry_attempts" => config.retry_attempts = value.parse().unwrap_or(3),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool(value: &str, fallback: bool) -> bool {
|
||||||
|
match value.trim().to_lowercase().as_str() {
|
||||||
|
"1" | "true" | "yes" | "on" => true,
|
||||||
|
"0" | "false" | "no" | "off" => false,
|
||||||
|
_ => fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_agent_config() {
|
||||||
|
let config = AgentConfig::parse_toml_like(
|
||||||
|
r#"
|
||||||
|
server_url = "https://awatch.local/api/telemetry"
|
||||||
|
api_key = "change-me"
|
||||||
|
collect_interval_seconds = 30
|
||||||
|
role = "firewall"
|
||||||
|
enable_processes = false
|
||||||
|
spool_dir = "/tmp/awatch-spool"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(config.role, AgentRole::Firewall);
|
||||||
|
assert_eq!(config.collect_interval_seconds, 30);
|
||||||
|
assert!(!config.enable_processes);
|
||||||
|
assert_eq!(config.spool_dir, PathBuf::from("/tmp/awatch-spool"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
mod collectors;
|
||||||
|
mod config;
|
||||||
|
mod telemetry;
|
||||||
|
mod transport;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use config::{AgentConfig, AgentRole, default_config_path};
|
||||||
|
use transport::{TelemetryTransport, spool_health};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(about = "AWatch-rus Rust telemetry agent")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(long, env = "AWATCH_AGENT_CONFIG")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long, env = "AWATCH_AGENT_SERVER_URL")]
|
||||||
|
server_url: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, env = "AWATCH_AGENT_API_KEY")]
|
||||||
|
api_key: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, env = "AWATCH_AGENT_ROLE")]
|
||||||
|
role: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
once: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
print_json: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
flush_spool: bool,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
spool_health: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let code = match run() {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err:#}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<i32> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let mut config = load_config(cli.config.as_ref())?;
|
||||||
|
if let Some(server_url) = cli.server_url {
|
||||||
|
config.server_url = server_url;
|
||||||
|
}
|
||||||
|
if let Some(api_key) = cli.api_key {
|
||||||
|
config.api_key = api_key;
|
||||||
|
}
|
||||||
|
if let Some(role) = cli.role {
|
||||||
|
config.role = AgentRole::parse(&role);
|
||||||
|
}
|
||||||
|
if cli.spool_health {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&spool_health(&config.spool_dir))?
|
||||||
|
);
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport = TelemetryTransport::new(&config);
|
||||||
|
if cli.flush_spool {
|
||||||
|
let flushed = transport.flush_spool()?;
|
||||||
|
println!("{}", serde_json::json!({"ok": true, "flushed": flushed}));
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let collector = collectors::platform_collector(config.role)?;
|
||||||
|
let record = collector.collect_all()?;
|
||||||
|
if cli.print_json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&record)?);
|
||||||
|
} else if let Err(err) = transport.send_or_spool(&record) {
|
||||||
|
eprintln!("{err:#}");
|
||||||
|
}
|
||||||
|
if cli.once {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_secs(config.collect_interval_seconds));
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config(path: Option<&PathBuf>) -> Result<AgentConfig> {
|
||||||
|
let path = path.cloned().unwrap_or_else(default_config_path);
|
||||||
|
if path.exists() {
|
||||||
|
AgentConfig::load(&path)
|
||||||
|
} else {
|
||||||
|
AgentConfig::parse_toml_like("")
|
||||||
|
.with_context(|| format!("load default config because {} is absent", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const COLLECTOR_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct TelemetryRecord {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub os_name: String,
|
||||||
|
pub os_version: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub username: String,
|
||||||
|
pub domain: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub cpu_usage_percent: f64,
|
||||||
|
pub memory_total: u64,
|
||||||
|
pub memory_used: u64,
|
||||||
|
pub active_sessions: Vec<SessionInfo>,
|
||||||
|
pub rdp_sessions: Vec<SessionInfo>,
|
||||||
|
pub ssh_sessions: Vec<SessionInfo>,
|
||||||
|
pub processes: Vec<ProcessInfo>,
|
||||||
|
pub network_interfaces: Vec<NetworkInterfaceInfo>,
|
||||||
|
pub network_connections: Vec<NetworkConnectionInfo>,
|
||||||
|
pub workforce_activity: WorkforceActivityInfo,
|
||||||
|
pub security_events: Vec<SecurityEventInfo>,
|
||||||
|
pub collector_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct IdentityInfo {
|
||||||
|
pub agent_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub os_name: String,
|
||||||
|
pub os_version: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub username: String,
|
||||||
|
pub domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ResourceInfo {
|
||||||
|
pub uptime_seconds: u64,
|
||||||
|
pub cpu_usage_percent: f64,
|
||||||
|
pub memory_total: u64,
|
||||||
|
pub memory_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub session_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub session_type: String,
|
||||||
|
pub remote_addr: Option<String>,
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ProcessInfo {
|
||||||
|
pub pid: u32,
|
||||||
|
pub ppid: Option<u32>,
|
||||||
|
pub name: String,
|
||||||
|
pub exe: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub cpu_percent: Option<f64>,
|
||||||
|
pub memory_bytes: Option<u64>,
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct NetworkInterfaceInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub mac: Option<String>,
|
||||||
|
pub addresses: Vec<String>,
|
||||||
|
pub up: bool,
|
||||||
|
pub rx_bytes: Option<u64>,
|
||||||
|
pub tx_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct NetworkConnectionInfo {
|
||||||
|
pub protocol: String,
|
||||||
|
pub local_addr: String,
|
||||||
|
pub local_port: u16,
|
||||||
|
pub remote_addr: Option<String>,
|
||||||
|
pub remote_port: Option<u16>,
|
||||||
|
pub state: String,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct WorkforceActivityInfo {
|
||||||
|
pub active_today: bool,
|
||||||
|
pub activity_index: Option<u8>,
|
||||||
|
pub department: Option<String>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub work_applications: Vec<String>,
|
||||||
|
pub idle_seconds: Option<u64>,
|
||||||
|
pub explanation: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SecurityEventInfo {
|
||||||
|
pub event_id: String,
|
||||||
|
pub source: String,
|
||||||
|
pub severity: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub evidence: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SessionSnapshot {
|
||||||
|
pub active_sessions: Vec<SessionInfo>,
|
||||||
|
pub rdp_sessions: Vec<SessionInfo>,
|
||||||
|
pub ssh_sessions: Vec<SessionInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct NetworkSnapshot {
|
||||||
|
pub interfaces: Vec<NetworkInterfaceInfo>,
|
||||||
|
pub connections: Vec<NetworkConnectionInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TelemetryCollector {
|
||||||
|
fn collect_identity(&self) -> Result<IdentityInfo>;
|
||||||
|
fn collect_sessions(&self) -> Result<SessionSnapshot>;
|
||||||
|
fn collect_processes(&self) -> Result<Vec<ProcessInfo>>;
|
||||||
|
fn collect_resources(&self) -> Result<ResourceInfo>;
|
||||||
|
fn collect_network(&self) -> Result<NetworkSnapshot>;
|
||||||
|
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>>;
|
||||||
|
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo>;
|
||||||
|
|
||||||
|
fn collect_all(&self) -> Result<TelemetryRecord> {
|
||||||
|
let identity = self.collect_identity()?;
|
||||||
|
let sessions = self.collect_sessions()?;
|
||||||
|
let resources = self.collect_resources()?;
|
||||||
|
let network = self.collect_network()?;
|
||||||
|
Ok(TelemetryRecord {
|
||||||
|
agent_id: identity.agent_id,
|
||||||
|
hostname: identity.hostname,
|
||||||
|
os_name: identity.os_name,
|
||||||
|
os_version: identity.os_version,
|
||||||
|
platform: identity.platform,
|
||||||
|
username: identity.username,
|
||||||
|
domain: identity.domain,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
uptime_seconds: resources.uptime_seconds,
|
||||||
|
cpu_usage_percent: resources.cpu_usage_percent,
|
||||||
|
memory_total: resources.memory_total,
|
||||||
|
memory_used: resources.memory_used,
|
||||||
|
active_sessions: sessions.active_sessions,
|
||||||
|
rdp_sessions: sessions.rdp_sessions,
|
||||||
|
ssh_sessions: sessions.ssh_sessions,
|
||||||
|
processes: self.collect_processes()?,
|
||||||
|
network_interfaces: network.interfaces,
|
||||||
|
network_connections: network.connections,
|
||||||
|
workforce_activity: self.collect_workforce_activity()?,
|
||||||
|
security_events: self.collect_security_events()?,
|
||||||
|
collector_version: COLLECTOR_VERSION.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_workforce_activity() -> WorkforceActivityInfo {
|
||||||
|
WorkforceActivityInfo {
|
||||||
|
active_today: false,
|
||||||
|
activity_index: None,
|
||||||
|
department: None,
|
||||||
|
owner: None,
|
||||||
|
work_applications: Vec::new(),
|
||||||
|
idle_seconds: None,
|
||||||
|
explanation: vec!["activity scoring requires workstation activity events".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_record_serializes_required_fields() {
|
||||||
|
let record = TelemetryRecord {
|
||||||
|
agent_id: "agent-1".to_string(),
|
||||||
|
hostname: "HOST-EXAMPLE".to_string(),
|
||||||
|
os_name: "Linux".to_string(),
|
||||||
|
os_version: "test".to_string(),
|
||||||
|
platform: "linux".to_string(),
|
||||||
|
username: "user".to_string(),
|
||||||
|
domain: "".to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
uptime_seconds: 1,
|
||||||
|
cpu_usage_percent: 0.0,
|
||||||
|
memory_total: 10,
|
||||||
|
memory_used: 5,
|
||||||
|
active_sessions: Vec::new(),
|
||||||
|
rdp_sessions: Vec::new(),
|
||||||
|
ssh_sessions: Vec::new(),
|
||||||
|
processes: Vec::new(),
|
||||||
|
network_interfaces: Vec::new(),
|
||||||
|
network_connections: Vec::new(),
|
||||||
|
workforce_activity: empty_workforce_activity(),
|
||||||
|
security_events: Vec::new(),
|
||||||
|
collector_version: COLLECTOR_VERSION.to_string(),
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(record).unwrap();
|
||||||
|
assert_eq!(value["agent_id"], "agent-1");
|
||||||
|
assert!(value.get("network_connections").unwrap().is_array());
|
||||||
|
assert!(value.get("workforce_activity").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use chrono::Utc;
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
|
use crate::config::AgentConfig;
|
||||||
|
use crate::telemetry::TelemetryRecord;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TelemetryTransport {
|
||||||
|
server_url: String,
|
||||||
|
api_key: String,
|
||||||
|
spool_dir: PathBuf,
|
||||||
|
timeout: Duration,
|
||||||
|
retry_attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelemetryTransport {
|
||||||
|
pub fn new(config: &AgentConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
server_url: config.server_url.clone(),
|
||||||
|
api_key: config.api_key.clone(),
|
||||||
|
spool_dir: config.spool_dir.clone(),
|
||||||
|
timeout: Duration::from_secs(config.timeout_seconds),
|
||||||
|
retry_attempts: config.retry_attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_or_spool(&self, record: &TelemetryRecord) -> Result<()> {
|
||||||
|
match self.send(record) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) => {
|
||||||
|
self.spool(record)?;
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, record: &TelemetryRecord) -> Result<()> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(self.timeout)
|
||||||
|
.build()
|
||||||
|
.context("build telemetry HTTP client")?;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-api-key",
|
||||||
|
HeaderValue::from_str(&self.api_key).context("invalid api key header")?,
|
||||||
|
);
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 0..self.retry_attempts.max(1) {
|
||||||
|
let result = client
|
||||||
|
.post(&self.server_url)
|
||||||
|
.headers(headers.clone())
|
||||||
|
.json(record)
|
||||||
|
.send()
|
||||||
|
.and_then(|response| response.error_for_status())
|
||||||
|
.map(|_| ());
|
||||||
|
match result {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(err) => {
|
||||||
|
last_error = Some(err);
|
||||||
|
let backoff = Duration::from_millis(250 * u64::from(attempt + 1));
|
||||||
|
thread::sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow!(
|
||||||
|
"telemetry POST failed: {}",
|
||||||
|
last_error
|
||||||
|
.map(|err| err.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown error".to_string())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spool(&self, record: &TelemetryRecord) -> Result<PathBuf> {
|
||||||
|
fs::create_dir_all(&self.spool_dir)
|
||||||
|
.with_context(|| format!("create spool {}", self.spool_dir.display()))?;
|
||||||
|
let file_name = format!(
|
||||||
|
"{}-{}.json",
|
||||||
|
record.timestamp.format("%Y%m%dT%H%M%S%.3fZ"),
|
||||||
|
sanitize_file_part(&record.agent_id)
|
||||||
|
);
|
||||||
|
let path = self.spool_dir.join(file_name);
|
||||||
|
fs::write(&path, serde_json::to_vec(record)?)
|
||||||
|
.with_context(|| format!("write spool {}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush_spool(&self) -> Result<usize> {
|
||||||
|
flush_spool_dir(&self.spool_dir, |record| self.send(record))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush_spool_dir<F>(spool_dir: &Path, mut sender: F) -> Result<usize>
|
||||||
|
where
|
||||||
|
F: FnMut(&TelemetryRecord) -> Result<()>,
|
||||||
|
{
|
||||||
|
if !spool_dir.exists() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut sent = 0;
|
||||||
|
let mut entries = fs::read_dir(spool_dir)
|
||||||
|
.with_context(|| format!("read spool {}", spool_dir.display()))?
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
entries.sort();
|
||||||
|
for path in entries {
|
||||||
|
let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
let record: TelemetryRecord =
|
||||||
|
serde_json::from_slice(&data).with_context(|| format!("parse {}", path.display()))?;
|
||||||
|
sender(&record)?;
|
||||||
|
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
|
Ok(sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_file_part(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spool_health(spool_dir: &Path) -> serde_json::Value {
|
||||||
|
let queued = fs::read_dir(spool_dir)
|
||||||
|
.ok()
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||||
|
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
|
||||||
|
.count();
|
||||||
|
serde_json::json!({
|
||||||
|
"generated_at_utc": Utc::now(),
|
||||||
|
"spool_dir": spool_dir.display().to_string(),
|
||||||
|
"queued": queued,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::telemetry::{TelemetryRecord, empty_workforce_activity};
|
||||||
|
|
||||||
|
fn record() -> TelemetryRecord {
|
||||||
|
TelemetryRecord {
|
||||||
|
agent_id: "agent/1".to_string(),
|
||||||
|
hostname: "HOST-EXAMPLE".to_string(),
|
||||||
|
os_name: "Linux".to_string(),
|
||||||
|
os_version: "test".to_string(),
|
||||||
|
platform: "linux".to_string(),
|
||||||
|
username: "user".to_string(),
|
||||||
|
domain: "".to_string(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
uptime_seconds: 1,
|
||||||
|
cpu_usage_percent: 0.0,
|
||||||
|
memory_total: 1,
|
||||||
|
memory_used: 1,
|
||||||
|
active_sessions: Vec::new(),
|
||||||
|
rdp_sessions: Vec::new(),
|
||||||
|
ssh_sessions: Vec::new(),
|
||||||
|
processes: Vec::new(),
|
||||||
|
network_interfaces: Vec::new(),
|
||||||
|
network_connections: Vec::new(),
|
||||||
|
workforce_activity: empty_workforce_activity(),
|
||||||
|
security_events: Vec::new(),
|
||||||
|
collector_version: "test".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spools_and_flushes_records() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let config = AgentConfig {
|
||||||
|
spool_dir: dir.path().to_path_buf(),
|
||||||
|
..AgentConfig::default()
|
||||||
|
};
|
||||||
|
let transport = TelemetryTransport::new(&config);
|
||||||
|
let path = transport.spool(&record()).unwrap();
|
||||||
|
assert!(path.is_file());
|
||||||
|
let mut seen = 0;
|
||||||
|
let flushed = flush_spool_dir(dir.path(), |_| {
|
||||||
|
seen += 1;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(flushed, 1);
|
||||||
|
assert_eq!(seen, 1);
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,20 @@ struct Cli {
|
|||||||
|
|
||||||
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN")]
|
#[arg(long, env = "DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN")]
|
||||||
evidence_upload_token: Option<String>,
|
evidence_upload_token: Option<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "change-me",
|
||||||
|
env = "DETMIR_PORTAL_TELEMETRY_API_KEY"
|
||||||
|
)]
|
||||||
|
telemetry_api_key: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "/var/lib/detmir-portal/telemetry.jsonl",
|
||||||
|
env = "DETMIR_PORTAL_TELEMETRY_STORE_PATH"
|
||||||
|
)]
|
||||||
|
telemetry_store_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
@@ -607,6 +621,9 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
if method == Method::Post && path == "/api/incidents/action" {
|
if method == Method::Post && path == "/api/incidents/action" {
|
||||||
return handle_incident_action(request, args);
|
return handle_incident_action(request, args);
|
||||||
}
|
}
|
||||||
|
if method == Method::Post && path == "/api/telemetry" {
|
||||||
|
return handle_telemetry_ingest(request, args);
|
||||||
|
}
|
||||||
if method != Method::Get {
|
if method != Method::Get {
|
||||||
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
|
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
|
||||||
}
|
}
|
||||||
@@ -3282,6 +3299,119 @@ fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> {
|
||||||
|
if !telemetry_authorized(&request, args) {
|
||||||
|
return respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(401),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": "telemetry api key is missing or invalid"
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut body = String::new();
|
||||||
|
request
|
||||||
|
.as_reader()
|
||||||
|
.take(1024 * 1024)
|
||||||
|
.read_to_string(&mut body)?;
|
||||||
|
let response = apply_telemetry_ingest(args, &body);
|
||||||
|
match response {
|
||||||
|
Ok(response) => respond_json(request, &response),
|
||||||
|
Err(err) => respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(400),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": err.to_string()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_telemetry_ingest(args: &Cli, body: &str) -> Result<Value> {
|
||||||
|
let payload: Value =
|
||||||
|
serde_json::from_str(body).map_err(|err| anyhow!("invalid telemetry JSON: {err}"))?;
|
||||||
|
validate_telemetry_payload(&payload)?;
|
||||||
|
let received_at_utc = now();
|
||||||
|
let envelope = json!({
|
||||||
|
"received_at_utc": received_at_utc,
|
||||||
|
"prototype": true,
|
||||||
|
"record": payload,
|
||||||
|
});
|
||||||
|
if let Some(parent) = args.telemetry_store_path.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&args.telemetry_store_path)
|
||||||
|
.with_context(|| format!("open {}", args.telemetry_store_path.display()))?;
|
||||||
|
writeln!(file, "{}", serde_json::to_string(&envelope)?)
|
||||||
|
.with_context(|| format!("append {}", args.telemetry_store_path.display()))?;
|
||||||
|
Ok(json!({
|
||||||
|
"ok": true,
|
||||||
|
"prototype": true,
|
||||||
|
"stored": "file-backed-jsonl",
|
||||||
|
"received_at_utc": received_at_utc,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_telemetry_payload(payload: &Value) -> Result<()> {
|
||||||
|
let Some(object) = payload.as_object() else {
|
||||||
|
return Err(anyhow!("telemetry payload must be a JSON object"));
|
||||||
|
};
|
||||||
|
for field in [
|
||||||
|
"agent_id",
|
||||||
|
"hostname",
|
||||||
|
"os_name",
|
||||||
|
"os_version",
|
||||||
|
"platform",
|
||||||
|
"username",
|
||||||
|
"timestamp",
|
||||||
|
"uptime_seconds",
|
||||||
|
"cpu_usage_percent",
|
||||||
|
"memory_total",
|
||||||
|
"memory_used",
|
||||||
|
"active_sessions",
|
||||||
|
"rdp_sessions",
|
||||||
|
"ssh_sessions",
|
||||||
|
"processes",
|
||||||
|
"network_interfaces",
|
||||||
|
"network_connections",
|
||||||
|
"workforce_activity",
|
||||||
|
"security_events",
|
||||||
|
"collector_version",
|
||||||
|
] {
|
||||||
|
if !object.contains_key(field) {
|
||||||
|
return Err(anyhow!("telemetry field is missing: {field}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field in [
|
||||||
|
"active_sessions",
|
||||||
|
"rdp_sessions",
|
||||||
|
"ssh_sessions",
|
||||||
|
"processes",
|
||||||
|
"network_interfaces",
|
||||||
|
"network_connections",
|
||||||
|
"security_events",
|
||||||
|
] {
|
||||||
|
if payload.get(field).and_then(Value::as_array).is_none() {
|
||||||
|
return Err(anyhow!("telemetry field must be an array: {field}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload
|
||||||
|
.get("workforce_activity")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(anyhow!(
|
||||||
|
"telemetry field must be an object: workforce_activity"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<IncidentActionResponse> {
|
fn apply_incident_action(args: &Cli, actor: &str, body: &str) -> Result<IncidentActionResponse> {
|
||||||
let action: IncidentActionRequest =
|
let action: IncidentActionRequest =
|
||||||
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?;
|
serde_json::from_str(body).map_err(|err| anyhow!("invalid incident action JSON: {err}"))?;
|
||||||
@@ -4059,6 +4189,24 @@ fn upload_authorized(request: &Request, args: &Cli) -> bool {
|
|||||||
constant_time_eq(actual.as_bytes(), expected.as_bytes())
|
constant_time_eq(actual.as_bytes(), expected.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn telemetry_authorized(request: &Request, args: &Cli) -> bool {
|
||||||
|
let expected = args.telemetry_api_key.trim();
|
||||||
|
if expected.is_empty() || expected == "change-me" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let actual = request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.find(|header| header.field.equiv("x-api-key"))
|
||||||
|
.map(|header| header.value.as_str().trim().to_string())
|
||||||
|
.or_else(|| bearer_token(request));
|
||||||
|
actual
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| constant_time_eq(value.as_bytes(), expected.as_bytes()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn bearer_token(request: &Request) -> Option<String> {
|
fn bearer_token(request: &Request) -> Option<String> {
|
||||||
request
|
request
|
||||||
.headers()
|
.headers()
|
||||||
@@ -4444,6 +4592,15 @@ fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn respond_json_status<T: Serialize>(
|
||||||
|
request: Request,
|
||||||
|
status: StatusCode,
|
||||||
|
value: &T,
|
||||||
|
) -> Result<()> {
|
||||||
|
let body = serde_json::to_string_pretty(value)?;
|
||||||
|
respond_text(request, status, &body, "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
fn respond_text(
|
fn respond_text(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
@@ -4610,6 +4767,8 @@ mod tests {
|
|||||||
json_smoke: false,
|
json_smoke: false,
|
||||||
evidence_only: false,
|
evidence_only: false,
|
||||||
evidence_upload_token: None,
|
evidence_upload_token: None,
|
||||||
|
telemetry_api_key: "test-key".to_string(),
|
||||||
|
telemetry_store_path: dir.path().join("telemetry.jsonl"),
|
||||||
};
|
};
|
||||||
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
|
let found = resolve_screenshot_file(&args, &None, &Some(digest.clone()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -4651,6 +4810,64 @@ mod tests {
|
|||||||
assert!(!constant_time_eq(b"secret", b"secret2"));
|
assert!(!constant_time_eq(b"secret", b"secret2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telemetry_ingest_validates_and_appends_jsonl() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let args = Cli {
|
||||||
|
bind: "127.0.0.1:0".to_string(),
|
||||||
|
status_cmd: "true".to_string(),
|
||||||
|
check_cmd: "true".to_string(),
|
||||||
|
failed_units_cmd: "true".to_string(),
|
||||||
|
worktime_url: "http://127.0.0.1".to_string(),
|
||||||
|
one_c_url: "http://127.0.0.1".to_string(),
|
||||||
|
workforce_policy_path: dir.path().join("workforce-policy.json"),
|
||||||
|
ueba_policy_path: dir.path().join("ueba-policy.yaml"),
|
||||||
|
timeout_seconds: 1,
|
||||||
|
state_dir: dir.path().join("state"),
|
||||||
|
dlp_db_path: dir.path().join("dlp.sqlite"),
|
||||||
|
evidence_root: dir.path().to_path_buf(),
|
||||||
|
readiness_bundle_dir: dir.path().join("readiness-bundle"),
|
||||||
|
evidence_limit: 10,
|
||||||
|
evidence_max_bytes: 1024,
|
||||||
|
json_smoke: false,
|
||||||
|
evidence_only: false,
|
||||||
|
evidence_upload_token: None,
|
||||||
|
telemetry_api_key: "test-key".to_string(),
|
||||||
|
telemetry_store_path: dir.path().join("telemetry/telemetry.jsonl"),
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"agent_id": "agent-1",
|
||||||
|
"hostname": "HOST-EXAMPLE",
|
||||||
|
"os_name": "Linux",
|
||||||
|
"os_version": "test",
|
||||||
|
"platform": "linux",
|
||||||
|
"username": "user",
|
||||||
|
"domain": "",
|
||||||
|
"timestamp": "2026-06-04T00:00:00Z",
|
||||||
|
"uptime_seconds": 1,
|
||||||
|
"cpu_usage_percent": 0.0,
|
||||||
|
"memory_total": 1,
|
||||||
|
"memory_used": 1,
|
||||||
|
"active_sessions": [],
|
||||||
|
"rdp_sessions": [],
|
||||||
|
"ssh_sessions": [],
|
||||||
|
"processes": [],
|
||||||
|
"network_interfaces": [],
|
||||||
|
"network_connections": [],
|
||||||
|
"workforce_activity": {"active_today": true, "explanation": []},
|
||||||
|
"security_events": [],
|
||||||
|
"collector_version": "0.3.0"
|
||||||
|
});
|
||||||
|
let response = apply_telemetry_ingest(&args, &serde_json::to_string(&payload).unwrap())
|
||||||
|
.expect("valid telemetry should be accepted");
|
||||||
|
assert_eq!(response["ok"], true);
|
||||||
|
assert_eq!(response["stored"], "file-backed-jsonl");
|
||||||
|
let stored = fs::read_to_string(&args.telemetry_store_path).unwrap();
|
||||||
|
assert!(stored.contains("\"prototype\":true"));
|
||||||
|
assert!(stored.contains("HOST-EXAMPLE"));
|
||||||
|
assert!(apply_telemetry_ingest(&args, r#"{"agent_id":"only"}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn human_duration_formats_hhmm() {
|
fn human_duration_formats_hhmm() {
|
||||||
assert_eq!(human_duration(0), "00:00");
|
assert_eq!(human_duration(0), "00:00");
|
||||||
|
|||||||
@@ -721,14 +721,16 @@ h1 {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leader-grid {
|
.leader-grid,
|
||||||
|
.investigation-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leader-grid > div {
|
.leader-grid > div,
|
||||||
|
.investigation-grid > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
|
|||||||
@@ -1179,10 +1179,80 @@ function renderDlpEvidence(evidence) {
|
|||||||
`).join("")}</div>`;
|
`).join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAutoInvestigation(report) {
|
||||||
|
report = periodReport(report || state.reports || {});
|
||||||
|
const rows = departmentRows(report);
|
||||||
|
const risky = [...rows].sort((a, b) => statusWeight(b.status) - statusWeight(a.status) || (a.activity ?? 101) - (b.activity ?? 101))[0];
|
||||||
|
const reasons = Array.isArray(report?.ueba_risk?.reasons) ? report.ueba_risk.reasons : [];
|
||||||
|
const risk = reasons[0] || {};
|
||||||
|
const status = risky?.status || risk.status || report?.ueba_risk?.status || "INFO";
|
||||||
|
const department = risky?.label || "Портфель";
|
||||||
|
const owner = risky?.responsible || "ответственный не назначен";
|
||||||
|
const generated = report?.generated_at_utc || new Date().toISOString();
|
||||||
|
const summary = risk.label || risk.code || risky?.reason || "Система сформировала риск-сигнал для ручной проверки";
|
||||||
|
return {
|
||||||
|
incident_id: `auto-${String(department).toLowerCase().replace(/[^a-zа-я0-9]+/gi, "-").replace(/^-|-$/g, "") || "risk"}`,
|
||||||
|
risk_id: risk.code || risk.label || "workforce-ueba-risk",
|
||||||
|
department,
|
||||||
|
owner,
|
||||||
|
activity_index: risky?.activityText || "нет данных",
|
||||||
|
deviation: risky?.deviation || "нет данных",
|
||||||
|
status,
|
||||||
|
summary,
|
||||||
|
why_it_is_risk: risky?.reason || risk.value || "риск может указывать на просадку активности, отклонение от нормы или событие безопасности",
|
||||||
|
what_to_check: risky?.check || risk.recommendation || "проверить первичные события ActivityWatch, RDP/1C активность, процессы и сетевые сигналы",
|
||||||
|
recommended_actions: [
|
||||||
|
"назначить ответственного за ручную проверку",
|
||||||
|
"сопоставить риск с журналами активности и бизнес-задачей",
|
||||||
|
"зафиксировать вывод в отчете по инциденту"
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
"RDP activity: проверяется по событиям рабочего времени",
|
||||||
|
"process activity: проверяется по процессам и приложениям",
|
||||||
|
"network activity: проверяется по сетевым сигналам",
|
||||||
|
"proxy activity: подключается как внешний источник",
|
||||||
|
"pfSense events: учитываются при наличии интеграционного слоя"
|
||||||
|
],
|
||||||
|
generated_at: generated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAutoInvestigationCard(report) {
|
||||||
|
const card = buildAutoInvestigation(report);
|
||||||
|
return `
|
||||||
|
<section class="card investigation-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>Автоматическая карточка расследования</h3>
|
||||||
|
<p class="muted">Расследование сформировано автоматически. Решение принимает ответственный сотрудник.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${statusClass(card.status)}">${ui(card.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="investigation-grid">
|
||||||
|
<div><span class="muted">incident_id</span><strong>${ui(card.incident_id)}</strong></div>
|
||||||
|
<div><span class="muted">risk_id</span><strong>${ui(card.risk_id)}</strong></div>
|
||||||
|
<div><span class="muted">department</span><strong>${ui(card.department)}</strong></div>
|
||||||
|
<div><span class="muted">owner</span><strong>${ui(card.owner)}</strong></div>
|
||||||
|
<div><span class="muted">activity_index</span><strong>${ui(card.activity_index)}</strong></div>
|
||||||
|
<div><span class="muted">deviation</span><strong>${ui(card.deviation)}</strong></div>
|
||||||
|
<div><span class="muted">generated_at</span><strong>${ui(card.generated_at)}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="list compact-list">
|
||||||
|
<div class="row compact-row"><strong>summary</strong><span class="muted">${ui(card.summary)}</span><span></span></div>
|
||||||
|
<div class="row compact-row"><strong>why_it_is_risk</strong><span class="muted">${ui(card.why_it_is_risk)}</span><span></span></div>
|
||||||
|
<div class="row compact-row"><strong>what_to_check</strong><span class="muted">${ui(card.what_to_check)}</span><span></span></div>
|
||||||
|
<div class="row compact-row"><strong>recommended_actions</strong><span class="muted">${ui(card.recommended_actions.join("; "))}</span><span></span></div>
|
||||||
|
<div class="row compact-row"><strong>evidence</strong><span class="muted">${ui(card.evidence.join("; "))}</span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderIncidents(data) {
|
function renderIncidents(data) {
|
||||||
const links = state.links || {};
|
const links = state.links || {};
|
||||||
const incidents = Array.isArray(data) ? data : data.incidents;
|
const incidents = Array.isArray(data) ? data : data.incidents;
|
||||||
const evidence = Array.isArray(data) ? null : data.evidence;
|
const evidence = Array.isArray(data) ? null : data.evidence;
|
||||||
|
const reports = Array.isArray(data) ? state.reports : data.reports;
|
||||||
return `
|
return `
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -1201,6 +1271,7 @@ function renderIncidents(data) {
|
|||||||
${renderDlpLinks(links)}
|
${renderDlpLinks(links)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
${renderAutoInvestigationCard(reports)}
|
||||||
<section class="card evidence-card">
|
<section class="card evidence-card">
|
||||||
<h3>Материалы: скриншоты, хеши, файлы</h3>
|
<h3>Материалы: скриншоты, хеши, файлы</h3>
|
||||||
${renderDlpEvidence(evidence)}
|
${renderDlpEvidence(evidence)}
|
||||||
@@ -1391,7 +1462,9 @@ async function refresh() {
|
|||||||
if (state.tab === "incidents") {
|
if (state.tab === "incidents") {
|
||||||
const data = await loadJson("/incidents");
|
const data = await loadJson("/incidents");
|
||||||
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
|
const evidence = await loadJson("/dlp/evidence").catch(error => ({ ok: false, error: error.message, items: [] }));
|
||||||
content.innerHTML = renderIncidents({ incidents: data, evidence });
|
const reports = await loadJson("/reports").catch(() => state.reports || {});
|
||||||
|
state.reports = reports;
|
||||||
|
content.innerHTML = renderIncidents({ incidents: data, evidence, reports });
|
||||||
}
|
}
|
||||||
if (state.tab === "perimeter") {
|
if (state.tab === "perimeter") {
|
||||||
const data = await loadJson("/owner");
|
const data = await loadJson("/owner");
|
||||||
@@ -1450,6 +1523,7 @@ document.addEventListener("click", event => {
|
|||||||
document.addEventListener("click", event => {
|
document.addEventListener("click", event => {
|
||||||
const button = event.target.closest("[data-open-investigation]");
|
const button = event.target.closest("[data-open-investigation]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
state.investigationRequested = true;
|
||||||
setTab("incidents");
|
setTab("incidents");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# AWatch-rus Rust Agent Architecture
|
||||||
|
|
||||||
|
Документ описывает агентный слой AWatch-rus v0.3.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
`awatch-agent-rs` является собственным Rust-агентом телеметрии для контура:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent -> Telemetry -> Analytics -> Risk -> Investigation -> Report
|
||||||
|
```
|
||||||
|
|
||||||
|
Агент собирает техническую и операционную телеметрию рабочего места или сервера и отправляет ее в серверный endpoint `POST /api/telemetry`.
|
||||||
|
|
||||||
|
## Границы реализации v0.3
|
||||||
|
|
||||||
|
- Linux collector собирает реальные данные через `/proc`, `/sys`, окружение сессии и системные журналы.
|
||||||
|
- Windows collector имеет стабильный публичный интерфейс и подготовлен под WinAPI, ETW, Event Log API и WMI-библиотеки Rust.
|
||||||
|
- FreeBSD collector имеет стабильный публичный интерфейс и подготовлен под `sysctl`, `procstat`, `kvm` и стандартные интерфейсы FreeBSD.
|
||||||
|
- PowerShell не является основным механизмом сбора. Он допускается только как будущий `legacy` fallback под feature flag.
|
||||||
|
- Агент не содержит скрытых функций, драйверов ядра, кейлоггера, записи экрана, перехвата документов и контентного DLP-анализа.
|
||||||
|
|
||||||
|
## Crate
|
||||||
|
|
||||||
|
```text
|
||||||
|
adk-rust/crates/awatch-agent-rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main.rs
|
||||||
|
src/config.rs
|
||||||
|
src/telemetry.rs
|
||||||
|
src/transport.rs
|
||||||
|
src/collectors/mod.rs
|
||||||
|
src/collectors/common.rs
|
||||||
|
src/collectors/linux.rs
|
||||||
|
src/collectors/windows.rs
|
||||||
|
src/collectors/freebsd.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Единая модель TelemetryRecord
|
||||||
|
|
||||||
|
Все платформы должны отдавать один JSON-контракт:
|
||||||
|
|
||||||
|
- `agent_id`
|
||||||
|
- `hostname`
|
||||||
|
- `os_name`
|
||||||
|
- `os_version`
|
||||||
|
- `platform`
|
||||||
|
- `username`
|
||||||
|
- `domain`
|
||||||
|
- `timestamp`
|
||||||
|
- `uptime_seconds`
|
||||||
|
- `cpu_usage_percent`
|
||||||
|
- `memory_total`
|
||||||
|
- `memory_used`
|
||||||
|
- `active_sessions`
|
||||||
|
- `rdp_sessions`
|
||||||
|
- `ssh_sessions`
|
||||||
|
- `processes`
|
||||||
|
- `network_interfaces`
|
||||||
|
- `network_connections`
|
||||||
|
- `workforce_activity`
|
||||||
|
- `security_events`
|
||||||
|
- `collector_version`
|
||||||
|
|
||||||
|
Дополнительные структуры: `SessionInfo`, `ProcessInfo`, `NetworkInterfaceInfo`, `NetworkConnectionInfo`, `WorkforceActivityInfo`, `SecurityEventInfo`.
|
||||||
|
|
||||||
|
## Collector trait
|
||||||
|
|
||||||
|
`TelemetryCollector` задает единый контракт:
|
||||||
|
|
||||||
|
```text
|
||||||
|
collect_identity()
|
||||||
|
collect_sessions()
|
||||||
|
collect_processes()
|
||||||
|
collect_resources()
|
||||||
|
collect_network()
|
||||||
|
collect_security_events()
|
||||||
|
collect_workforce_activity()
|
||||||
|
collect_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
Такой интерфейс позволяет расширять Windows, Linux, FreeBSD и pfSense mode без изменения серверного API.
|
||||||
|
|
||||||
|
## Транспорт
|
||||||
|
|
||||||
|
Транспорт использует JSON over HTTPS:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/telemetry
|
||||||
|
x-api-key: <api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
При недоступности сервера запись не теряется: агент сохраняет JSON в spool и повторно отправляет после восстановления связи.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# AWatch-rus BSD Support
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
BSD-слой нужен для будущей поддержки FreeBSD и pfSense mode без изменения публичного `TelemetryRecord`.
|
||||||
|
|
||||||
|
## FreeBSD collector
|
||||||
|
|
||||||
|
В v0.3 FreeBSD collector имеет стабильный интерфейс и честно возвращает ограничение реализации, если бинарник собран под FreeBSD до включения нативных источников.
|
||||||
|
|
||||||
|
Планируемые источники:
|
||||||
|
|
||||||
|
- `sysctl`;
|
||||||
|
- `procstat`;
|
||||||
|
- `kvm`;
|
||||||
|
- сетевые интерфейсы ОС;
|
||||||
|
- системный syslog;
|
||||||
|
- сведения об SSH-сессиях.
|
||||||
|
|
||||||
|
## pfSense mode
|
||||||
|
|
||||||
|
Роль:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awatch-agent-rs --role firewall
|
||||||
|
```
|
||||||
|
|
||||||
|
Назначение: read-only сбор телеметрии сетевого периметра.
|
||||||
|
|
||||||
|
Будущие сигналы:
|
||||||
|
|
||||||
|
- interfaces;
|
||||||
|
- gateway status;
|
||||||
|
- VPN sessions;
|
||||||
|
- firewall counters;
|
||||||
|
- pf statistics;
|
||||||
|
- NAT counters;
|
||||||
|
- DNS statistics;
|
||||||
|
- Suricata summary, если установлен;
|
||||||
|
- Unbound summary, если установлен.
|
||||||
|
|
||||||
|
## Ограничение безопасности
|
||||||
|
|
||||||
|
pfSense mode не должен менять правила firewall, NAT, DNS, VPN или маршрутизацию. В рамках v0.3 это только наблюдение.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# AWatch-rus Agent Deployment
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
server_url = "https://awatch.local/api/telemetry"
|
||||||
|
api_key = "change-me"
|
||||||
|
collect_interval_seconds = 60
|
||||||
|
role = "workstation"
|
||||||
|
|
||||||
|
enable_processes = true
|
||||||
|
enable_network = true
|
||||||
|
enable_security_events = true
|
||||||
|
enable_workforce_activity = true
|
||||||
|
|
||||||
|
spool_dir = "/var/lib/awatch-agent/spool"
|
||||||
|
timeout_seconds = 10
|
||||||
|
retry_attempts = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux/BSD путь по умолчанию:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/awatch-agent/awatch-agent.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows путь по умолчанию:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\AWatch\agent\awatch-agent.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Однократный сбор и печать JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awatch-agent-rs --once --print-json
|
||||||
|
```
|
||||||
|
|
||||||
|
Однократная отправка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awatch-agent-rs --once --config /etc/awatch-agent/awatch-agent.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка очереди:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awatch-agent-rs --spool-health
|
||||||
|
```
|
||||||
|
|
||||||
|
Отправка накопленной очереди:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awatch-agent-rs --flush-spool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server API
|
||||||
|
|
||||||
|
Портал принимает телеметрию через:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/telemetry
|
||||||
|
```
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
|
||||||
|
- JSON object;
|
||||||
|
- заголовок `x-api-key` или `Authorization: Bearer`;
|
||||||
|
- обязательные поля `TelemetryRecord`;
|
||||||
|
- в v0.3 хранение является prototype file-backed JSONL.
|
||||||
|
|
||||||
|
Серверные переменные:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DETMIR_PORTAL_TELEMETRY_API_KEY
|
||||||
|
DETMIR_PORTAL_TELEMETRY_STORE_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию `change-me` не авторизует прием телеметрии. Для пилота ключ нужно задать явно.
|
||||||
|
|
||||||
|
## Проверка после установки
|
||||||
|
|
||||||
|
1. Запустить агент с `--once --print-json`.
|
||||||
|
2. Проверить наличие `TelemetryRecord` и `collector_version`.
|
||||||
|
3. Запустить агент с реальным `server_url`.
|
||||||
|
4. Проверить HTTP-ответ `{ "ok": true, "stored": "file-backed-jsonl" }`.
|
||||||
|
5. Проверить отсутствие файлов в spool после успешной отправки.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# pfSense Integration
|
||||||
|
|
||||||
|
pfSense рассматривается как интеграционный слой сетевого периметра, а не обязательная часть продукта.
|
||||||
|
|
||||||
|
## Режим v0.3
|
||||||
|
|
||||||
|
- read-only;
|
||||||
|
- без изменения правил;
|
||||||
|
- без автоматического карантина;
|
||||||
|
- без управления маршрутизацией;
|
||||||
|
- без зависимости портала от pfSense.
|
||||||
|
|
||||||
|
## Место в архитектуре
|
||||||
|
|
||||||
|
```text
|
||||||
|
pfSense / firewall telemetry
|
||||||
|
|
|
||||||
|
awatch-agent-rs --role firewall
|
||||||
|
|
|
||||||
|
POST /api/telemetry
|
||||||
|
|
|
||||||
|
Workforce/UEBA risk context
|
||||||
|
```
|
||||||
|
|
||||||
|
## Коммерческая ценность
|
||||||
|
|
||||||
|
Интеграция позволяет объяснять риски не только по активности рабочего места, но и по сетевому контексту: unusual destinations, VPN sessions, gateway status, proxy/DNS signals.
|
||||||
|
|
||||||
|
## Не реализуется в v0.3
|
||||||
|
|
||||||
|
- NAC;
|
||||||
|
- SOAR-автоматизация;
|
||||||
|
- блокировка VLAN;
|
||||||
|
- изменение firewall rules;
|
||||||
|
- управление VPN-доступом.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# AWatch-rus Portal
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Портал является рабочим кабинетом для трех ролей:
|
||||||
|
|
||||||
|
- руководитель: пульс организации, активность, подразделения, риски;
|
||||||
|
- ИБ: риск-сигналы, события безопасности, evidence;
|
||||||
|
- расследователь: карточка инцидента, причины, проверяемые источники, отчет.
|
||||||
|
|
||||||
|
## Главные экраны
|
||||||
|
|
||||||
|
- `Обзор` - Executive Dashboard.
|
||||||
|
- `Сотрудники` - карточки сотрудников и объяснение индекса.
|
||||||
|
- `Подразделения` - сравнение групп, тренды и ответственные.
|
||||||
|
- `Риски` - read-only контроль безопасности.
|
||||||
|
- `Расследования` - инциденты, evidence и автоматическая read-only карточка расследования.
|
||||||
|
- `Сетевой периметр` - read-only контекст внешних сетевых сигналов.
|
||||||
|
- `Отчеты` - Markdown/PDF/JSON и управленческий текст.
|
||||||
|
- `Настройки` - read-only параметры расчета.
|
||||||
|
|
||||||
|
## Executive Dashboard
|
||||||
|
|
||||||
|
Показывает:
|
||||||
|
|
||||||
|
- сотрудников в работе;
|
||||||
|
- средний индекс активности;
|
||||||
|
- WARN/FAIL подразделения;
|
||||||
|
- критические риски;
|
||||||
|
- недельный тренд;
|
||||||
|
- топ-5 лучших и проблемных подразделений;
|
||||||
|
- Heat Map;
|
||||||
|
- блок `Требует внимания`.
|
||||||
|
|
||||||
|
## Risk -> Investigation
|
||||||
|
|
||||||
|
Кнопка `Открыть расследование` переводит пользователя в read-only карточку.
|
||||||
|
|
||||||
|
Карточка содержит:
|
||||||
|
|
||||||
|
- `incident_id`;
|
||||||
|
- `risk_id`;
|
||||||
|
- `department`;
|
||||||
|
- `owner`;
|
||||||
|
- `activity_index`;
|
||||||
|
- `deviation`;
|
||||||
|
- `status`;
|
||||||
|
- `summary`;
|
||||||
|
- `why_it_is_risk`;
|
||||||
|
- `what_to_check`;
|
||||||
|
- `recommended_actions`;
|
||||||
|
- `evidence`;
|
||||||
|
- `generated_at`.
|
||||||
|
|
||||||
|
Предупреждение обязательно:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Расследование сформировано автоматически. Решение принимает ответственный сотрудник.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
Smoke-тест:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/detmir-portal-tabs-smoke.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Тест проверяет все вкладки, read-only настройки и переход Risk -> Investigation.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Workforce / UEBA Model
|
||||||
|
|
||||||
|
## Позиционирование
|
||||||
|
|
||||||
|
AWatch-rus v0.3 использует Workforce-first подход:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Телеметрия -> Активность -> Риск -> Расследование -> Отчет
|
||||||
|
```
|
||||||
|
|
||||||
|
Это не сертифицированная СЗИ, не промышленная SIEM и не полнофункциональная DLP. Корректная формулировка для v0.3:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UEBA-compatible rule-based risk scoring v1.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workforce scoring
|
||||||
|
|
||||||
|
Расчетные поля:
|
||||||
|
|
||||||
|
- `activity_index`;
|
||||||
|
- `active_today`;
|
||||||
|
- `department_activity_index`;
|
||||||
|
- `owner_activity_index`;
|
||||||
|
- `trend_status`;
|
||||||
|
- `anomaly_status`;
|
||||||
|
- `risk_level`.
|
||||||
|
|
||||||
|
Базовая proxy-формула:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Индекс активности = active_seconds / planned_seconds * 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Взвешенный индекс использует роли и веса приложений, если задан workforce policy.
|
||||||
|
|
||||||
|
## Статусы
|
||||||
|
|
||||||
|
- `OK` - состояние в норме;
|
||||||
|
- `WARN` - требуется внимание;
|
||||||
|
- `FAIL` - требуется действие.
|
||||||
|
|
||||||
|
Каждый риск обязан объяснять:
|
||||||
|
|
||||||
|
- что произошло;
|
||||||
|
- почему это риск;
|
||||||
|
- что проверить;
|
||||||
|
- рекомендуемое действие.
|
||||||
|
|
||||||
|
## UEBA risk v1
|
||||||
|
|
||||||
|
Риск рассчитывается по правилам, а не по скрытой ML-модели.
|
||||||
|
|
||||||
|
Поля объяснимости:
|
||||||
|
|
||||||
|
- `confidence`;
|
||||||
|
- `risk_sources`;
|
||||||
|
- `baseline_status`;
|
||||||
|
- `policy_version`;
|
||||||
|
- `calculated_from`;
|
||||||
|
- `baseline_window_days`;
|
||||||
|
- `user_baseline_available`;
|
||||||
|
- `department_baseline_available`;
|
||||||
|
- `deviation_score`;
|
||||||
|
- `baseline_samples`.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Evidence повышает достоверность вывода, но само по себе не является фактором риска. Риск должен исходить из события, отклонения, тренда или правила.
|
||||||
|
|
||||||
|
## Не реализуется
|
||||||
|
|
||||||
|
- перехват содержимого документов;
|
||||||
|
- запись экрана;
|
||||||
|
- кейлоггер;
|
||||||
|
- скрытый агент;
|
||||||
|
- контентный DLP-анализ;
|
||||||
|
- автоматическое редактирование инцидентов;
|
||||||
|
- автоматическое наказание или блокировка сотрудника.
|
||||||
@@ -163,9 +163,14 @@ async function main() {
|
|||||||
{ timeout },
|
{ timeout },
|
||||||
);
|
);
|
||||||
const activeTab = await page.locator(".tab.is-active").innerText({ timeout });
|
const activeTab = await page.locator(".tab.is-active").innerText({ timeout });
|
||||||
|
const incidentText = await page.locator("#content").innerText({ timeout });
|
||||||
checks.push({
|
checks.push({
|
||||||
name: "risk_open_investigation_readonly_navigation",
|
name: "risk_open_investigation_readonly_navigation",
|
||||||
ok: activeTab.trim() === "Расследования",
|
ok: activeTab.trim() === "Расследования"
|
||||||
|
&& containsText(incidentText, "Расследование сформировано автоматически")
|
||||||
|
&& containsText(incidentText, "incident_id")
|
||||||
|
&& containsText(incidentText, "risk_id")
|
||||||
|
&& containsText(incidentText, "evidence"),
|
||||||
buttons: investigationButtons,
|
buttons: investigationButtons,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user