g1t/crates/runner/src/abuse.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! Catching cryptocurrency mining in a sandbox. |
| 2 | //! | |
| 3 | //! Mining needs two things a g1t sandbox tries not to give it: a route to a | |
| 4 | //! mining pool, which the network allowlist refuses, and hours of CPU, which | |
| 5 | //! this module watches for. Three layers: | |
| 6 | //! | |
| 7 | //! 1. **Known miners, by name.** `miner_in` matches miner programs (xmrig, | |
| 8 | //! cpuminer, ...), pool URLs (`stratum+tcp://`), their flags | |
| 9 | //! (`--donate-level`) and well-known pools. The agent harness's command | |
| 10 | //! hook refuses a matching shell command (guard.rs), checks, deploy | |
| 11 | //! builds and workflow steps refuse to run one, and the sampler below | |
| 12 | //! stops the sandbox if a running process's command line matches. | |
| 13 | //! 2. **The CPU signature.** A background thread samples the sandbox every | |
| 14 | //! `SAMPLE_SECONDS`: CPU use (from `/proc/stat`), file and disk I/O | |
| 15 | //! (`/proc/diskstats` and every process's `/proc/<pid>/io`), network | |
| 16 | //! bytes (`/proc/net/dev`), how many new processes started, and whether | |
| 17 | //! the run did anything a person would call progress (a tool call, an | |
| 18 | //! agent step, a new check command or workflow step). `Detector` flags a | |
| 19 | //! sandbox only when, for a whole `WINDOW_SECONDS` (10 minutes): | |
| 20 | //! - CPU stayed at or above `MIN_CPU` (90%) in every sample but one; | |
| 21 | //! - file and disk I/O averaged under `MAX_IO_PER_SECOND` (64 KB/s); | |
| 22 | //! - the network averaged under `MAX_NET_PER_SECOND` (16 KB/s); | |
| 23 | //! - fewer than `MAX_NEW_PROCESSES` (5) processes started; and | |
| 24 | //! - there was no progress at all. | |
| 25 | //! | |
| 26 | //! Compilers and test suites are CPU-bound too, but they read sources, | |
| 27 | //! write objects and start processes (a `cargo build` or `npm test` | |
| 28 | //! starts hundreds), so they fail the I/O or process test. A single | |
| 29 | //! compiler process can sit in code generation for minutes with little | |
| 30 | //! I/O, so when the busiest process is a known toolchain (`TOOLCHAINS`) | |
| 31 | //! and nothing matches a miner by name, it is not flagged. | |
| 32 | //! 3. **What happens.** The sandbox tells the runner service (an HTTP | |
| 33 | //! request to `REPORT_URL`, which the runner's Durable Object answers | |
| 34 | //! without it leaving the machine), kills every other process, and | |
| 35 | //! exits with `EXIT_CODE`. The runner marks the run "Stopped: unusual | |
| 36 | //! CPU use; contact support if this was a real job" and emits | |
| 37 | //! `abuse.flagged` for g1t's staff, with these metrics. | |
| 38 | ||
| 39 | use std::collections::{BTreeMap, VecDeque}; | |
| 40 | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 41 | use std::time::Duration; | |
| 42 | ||
| 43 | use serde::Serialize; | |
| 44 | ||
| 45 | /// How often the sandbox is sampled. | |
| 46 | pub const SAMPLE_SECONDS: u64 = 30; | |
| 47 | /// How long the signature must hold before a run is flagged. | |
| 48 | pub const WINDOW_SECONDS: u64 = 10 * 60; | |
| 49 | /// CPU use, of all the sandbox's CPUs, that counts as pinned. | |
| 50 | pub const MIN_CPU: f64 = 0.90; | |
| 51 | /// Samples in a window that may dip under `MIN_CPU` (a miner's own pauses). | |
| 52 | pub const ALLOWED_DIPS: usize = 1; | |
| 53 | /// File and disk I/O, in bytes a second, under which a run counts as doing none. | |
| 54 | pub const MAX_IO_PER_SECOND: f64 = 64.0 * 1024.0; | |
| 55 | /// Network, in bytes a second, under which a run counts as quiet. | |
| 56 | pub const MAX_NET_PER_SECOND: f64 = 16.0 * 1024.0; | |
| 57 | /// New processes in a window under which a run counts as not building. | |
| 58 | pub const MAX_NEW_PROCESSES: u64 = 5; | |
| 59 | /// What the sandbox exits with when it stops itself for mining. | |
| 60 | pub const EXIT_CODE: i32 = 86; | |
| 61 | /// Where the sandbox tells the runner; answered by the runner's Durable | |
| 62 | /// Object, never sent anywhere. | |
| 63 | pub const REPORT_URL: &str = "http://sandbox.g1t.internal/abuse"; | |
| 64 | ||
| 65 | /// Programs whose name alone says they mine. Matched anywhere in a command | |
| 66 | /// line, so downloads of them are caught too. | |
| 67 | const MINER_NAMES: &[&str] = &[ | |
| 68 | "xmrig", | |
| 69 | "xmr-stak", | |
| 70 | "cpuminer", | |
| 71 | "minerd", | |
| 72 | "ccminer", | |
| 73 | "cgminer", | |
| 74 | "bfgminer", | |
| 75 | "ethminer", | |
| 76 | "nbminer", | |
| 77 | "lolminer", | |
| 78 | "phoenixminer", | |
| 79 | "nanominer", | |
| 80 | "srbminer", | |
| 81 | "teamredminer", | |
| 82 | "bzminer", | |
| 83 | "nheqminer", | |
| 84 | "xmrminer", | |
| 85 | ]; | |
| 86 | ||
| 87 | /// Programs matched only as a whole program name: as words they are too | |
| 88 | /// common. | |
| 89 | const MINER_PROGRAMS: &[&str] = &["t-rex", "gminer", "wildrig", "rigel"]; | |
| 90 | ||
| 91 | /// Arguments and addresses only miners use. | |
| 92 | const MINER_ARGS: &[&str] = &[ | |
| 93 | "stratum+tcp://", | |
| 94 | "stratum+ssl://", | |
| 95 | "stratum+tls://", | |
| 96 | "stratum2+tcp://", | |
| 97 | "--donate-level", | |
| 98 | "--cpu-max-threads-hint", | |
| 99 | "--randomx-mode", | |
| 100 | "--algo=rx/", | |
| 101 | "--algo rx/", | |
| 102 | "-a rx/0", | |
| 103 | "--algo=cryptonight", | |
| 104 | "-a cryptonight", | |
| 105 | "--coin=monero", | |
| 106 | "--coin monero", | |
| 107 | "supportxmr.com", | |
| 108 | "moneroocean.stream", | |
| 109 | "minexmr.com", | |
| 110 | "nanopool.org", | |
| 111 | "2miners.com", | |
| 112 | "f2pool.com", | |
| 113 | "hashvault.pro", | |
| 114 | "unmineable.com", | |
| 115 | "nicehash.com", | |
| 116 | "herominers.com", | |
| 117 | "c3pool.com", | |
| 118 | ]; | |
| 119 | ||
| 120 | /// Toolchains that can keep a CPU busy for minutes with little I/O while | |
| 121 | /// they generate code. Busiest-process names, as `/proc/<pid>/stat` gives | |
| 122 | /// them (cut to 15 characters). | |
| 123 | const TOOLCHAINS: &[&str] = &[ | |
| 124 | "rustc", "cc1", "cc1plus", "clang", "clang++", "ld", "ld.lld", "lld", "mold", "go", "compile", "link", | |
| 125 | "javac", "java", "kotlinc", "scalac", "swift-frontend", "ghc", "node", "tsc", "esbuild", "webpack", "python3", | |
| 126 | "python", "pytest", "cargo", "gcc", "g++", "rust-analyzer", "dotnet", | |
| 127 | ]; | |
| 128 | ||
| 129 | /// The miner a command line names, if it names one. | |
| 130 | pub fn miner_in(command: &str) -> Option<&'static str> { | |
| 131 | let text = command.to_lowercase(); | |
| 132 | if let Some(name) = MINER_NAMES.iter().find(|name| text.contains(*name)) { | |
| 133 | return Some(name); | |
| 134 | } | |
| 135 | if let Some(arg) = MINER_ARGS.iter().find(|arg| text.contains(*arg)) { | |
| 136 | return Some(arg); | |
| 137 | } | |
| 138 | text.split(|c: char| c.is_whitespace() || matches!(c, ';' | '|' | '&' | '(' | ')' | '`' | '"' | '\'')) | |
| 139 | .map(|word| word.rsplit('/').next().unwrap_or(word)) | |
| 140 | .find_map(|program| MINER_PROGRAMS.iter().find(|name| **name == program).copied()) | |
| 141 | } | |
| 142 | ||
| 143 | /// What the run has done that a person would call progress, bumped by the | |
| 144 | /// harness on each tool call and step, and by checks and workflows on each | |
| 145 | /// command or step they start. | |
| 146 | static ACTIVITY: AtomicU64 = AtomicU64::new(0); | |
| 147 | ||
| 148 | /// Notes progress, so a busy CPU is not mistaken for a miner's. | |
| 149 | pub fn touch() { | |
| 150 | ACTIVITY.fetch_add(1, Ordering::Relaxed); | |
| 151 | } | |
| 152 | ||
| 153 | /// One reading of the sandbox. Counters are totals since boot; the | |
| 154 | /// detector works on the differences between readings. | |
| 155 | #[derive(Clone, Debug, Default)] | |
| 156 | pub struct Sample { | |
| 157 | /// Seconds, on any steady clock. | |
| 158 | pub at: u64, | |
| 159 | pub cpu_busy: u64, | |
| 160 | pub cpu_total: u64, | |
| 161 | /// File and disk bytes read and written. | |
| 162 | pub io_bytes: u64, | |
| 163 | pub net_bytes: u64, | |
| 164 | /// Processes seen for the first time since the last sample. | |
| 165 | pub new_processes: u64, | |
| 166 | pub activity: u64, | |
| 167 | /// The process that used the most CPU since the last sample, by name. | |
| 168 | pub busiest: Option<String>, | |
| 169 | /// A running process whose command line names a miner, if any. | |
| 170 | pub miner: Option<String>, | |
| 171 | } | |
| 172 | ||
| 173 | /// Why a sandbox was flagged, as reported and shown to g1t's staff. | |
| 174 | #[derive(Clone, Debug, PartialEq, Serialize)] | |
| 175 | pub struct Verdict { | |
| 176 | /// `cpu` for the signature, `miner` for a miner seen by name. | |
| 177 | pub reason: &'static str, | |
| 178 | /// Average CPU use over the window, 0 to 1. | |
| 179 | pub cpu: f64, | |
| 180 | pub io_bytes_per_second: f64, | |
| 181 | pub net_bytes_per_second: f64, | |
| 182 | pub new_processes: u64, | |
| 183 | pub window_seconds: u64, | |
| 184 | pub busiest: Option<String>, | |
| 185 | pub matched: Option<String>, | |
| 186 | } | |
| 187 | ||
| 188 | /// Watches samples for the signature of mining. | |
| 189 | #[derive(Default)] | |
| 190 | pub struct Detector { | |
| 191 | samples: VecDeque<Sample>, | |
| 192 | } | |
| 193 | ||
| 194 | impl Detector { | |
| 195 | /// Takes a sample; returns a verdict the first time the signature holds. | |
| 196 | pub fn observe(&mut self, sample: Sample) -> Option<Verdict> { | |
| 197 | if let Some(matched) = sample.miner.clone() { | |
| 198 | return Some(Verdict { | |
| 199 | reason: "miner", | |
| 200 | cpu: 0.0, | |
| 201 | io_bytes_per_second: 0.0, | |
| 202 | net_bytes_per_second: 0.0, | |
| 203 | new_processes: sample.new_processes, | |
| 204 | window_seconds: 0, | |
| 205 | busiest: sample.busiest.clone(), | |
| 206 | matched: Some(matched), | |
| 207 | }); | |
| 208 | } | |
| 209 | self.samples.push_back(sample); | |
| 210 | // Keep just over one window: the oldest sample is its start. | |
| 211 | while self.samples.len() > 2 | |
| 212 | && self.samples[1].at + WINDOW_SECONDS <= self.samples.back().map_or(0, |last| last.at) | |
| 213 | { | |
| 214 | self.samples.pop_front(); | |
| 215 | } | |
| 216 | let first = self.samples.front()?; | |
| 217 | let last = self.samples.back()?; | |
| 218 | let span = last.at.saturating_sub(first.at); | |
| 219 | if span < WINDOW_SECONDS { | |
| 220 | return None; | |
| 221 | } | |
| 222 | let pairs: Vec<(&Sample, &Sample)> = self.samples.iter().zip(self.samples.iter().skip(1)).collect(); | |
| 223 | let uses: Vec<f64> = pairs | |
| 224 | .iter() | |
| 225 | .map(|(a, b)| { | |
| 226 | let total = b.cpu_total.saturating_sub(a.cpu_total); | |
| 227 | if total == 0 { 0.0 } else { b.cpu_busy.saturating_sub(a.cpu_busy) as f64 / total as f64 } | |
| 228 | }) | |
| 229 | .collect(); | |
| 230 | let dips = uses.iter().filter(|cpu| **cpu < MIN_CPU).count(); | |
| 231 | if dips > ALLOWED_DIPS { | |
| 232 | return None; | |
| 233 | } | |
| 234 | let seconds = span as f64; | |
| 235 | let io = last.io_bytes.saturating_sub(first.io_bytes) as f64 / seconds; | |
| 236 | let net = last.net_bytes.saturating_sub(first.net_bytes) as f64 / seconds; | |
| 237 | let new_processes: u64 = self.samples.iter().skip(1).map(|s| s.new_processes).sum(); | |
| 238 | if io >= MAX_IO_PER_SECOND || net >= MAX_NET_PER_SECOND || new_processes >= MAX_NEW_PROCESSES { | |
| 239 | return None; | |
| 240 | } | |
| 241 | if last.activity != first.activity { | |
| 242 | return None; | |
| 243 | } | |
| 244 | // A compiler deep in code generation: busy, quiet, and expected. | |
| 245 | if last.busiest.as_deref().is_some_and(|name| TOOLCHAINS.contains(&name)) { | |
| 246 | return None; | |
| 247 | } | |
| 248 | Some(Verdict { | |
| 249 | reason: "cpu", | |
| 250 | cpu: uses.iter().sum::<f64>() / uses.len().max(1) as f64, | |
| 251 | io_bytes_per_second: io, | |
| 252 | net_bytes_per_second: net, | |
| 253 | new_processes, | |
| 254 | window_seconds: span, | |
| 255 | busiest: last.busiest.clone(), | |
| 256 | matched: None, | |
| 257 | }) | |
| 258 | } | |
| 259 | } | |
| 260 | ||
| 261 | // ---- Reading /proc ------------------------------------------------------------ | |
| 262 | ||
| 263 | /// Busy and total CPU ticks from `/proc/stat`'s first line. | |
| 264 | pub fn parse_cpu(stat: &str) -> Option<(u64, u64)> { | |
| 265 | let line = stat.lines().find(|line| line.starts_with("cpu "))?; | |
| 266 | let values: Vec<u64> = line.split_whitespace().skip(1).filter_map(|v| v.parse().ok()).collect(); | |
| 267 | if values.len() < 4 { | |
| 268 | return None; | |
| 269 | } | |
| 270 | // user nice system idle iowait irq softirq steal: guest time is in user. | |
| 271 | let counted = &values[..values.len().min(8)]; | |
| 272 | let total: u64 = counted.iter().sum(); | |
| 273 | let idle = values[3] + values.get(4).copied().unwrap_or(0); | |
| 274 | Some((total.saturating_sub(idle), total)) | |
| 275 | } | |
| 276 | ||
| 277 | /// Bytes received and sent on every interface but loopback, from `/proc/net/dev`. | |
| 278 | pub fn parse_net(dev: &str) -> u64 { | |
| 279 | dev.lines() | |
| 280 | .skip(2) | |
| 281 | .filter_map(|line| { | |
| 282 | let (name, rest) = line.split_once(':')?; | |
| 283 | if name.trim() == "lo" { | |
| 284 | return None; | |
| 285 | } | |
| 286 | let fields: Vec<u64> = rest.split_whitespace().filter_map(|v| v.parse().ok()).collect(); | |
| 287 | Some(fields.first().copied().unwrap_or(0) + fields.get(8).copied().unwrap_or(0)) | |
| 288 | }) | |
| 289 | .sum() | |
| 290 | } | |
| 291 | ||
| 292 | /// Bytes read and written on whole disks, from `/proc/diskstats`. | |
| 293 | pub fn parse_disks(stats: &str) -> u64 { | |
| 294 | stats | |
| 295 | .lines() | |
| 296 | .filter_map(|line| { | |
| 297 | let fields: Vec<&str> = line.split_whitespace().collect(); | |
| 298 | let name = *fields.get(2)?; | |
| 299 | let virtual_device = ["loop", "ram", "dm-", "zram", "sr"].iter().any(|prefix| name.starts_with(prefix)); | |
| 300 | let partition = if name.starts_with("nvme") || name.starts_with("mmcblk") { | |
| 301 | name.contains('p') | |
| 302 | } else { | |
| 303 | name.ends_with(|c: char| c.is_ascii_digit()) | |
| 304 | }; | |
| 305 | if virtual_device || partition { | |
| 306 | return None; | |
| 307 | } | |
| 308 | let read: u64 = fields.get(5)?.parse().ok()?; | |
| 309 | let written: u64 = fields.get(9)?.parse().ok()?; | |
| 310 | Some((read + written) * 512) | |
| 311 | }) | |
| 312 | .sum() | |
| 313 | } | |
| 314 | ||
| 315 | /// A process's name and CPU ticks (user + system), from `/proc/<pid>/stat`. | |
| 316 | pub fn parse_proc_stat(stat: &str) -> Option<(String, u64)> { | |
| 317 | let open = stat.find('(')?; | |
| 318 | let close = stat.rfind(')')?; | |
| 319 | let name = stat.get(open + 1..close)?.to_owned(); | |
| 320 | let rest: Vec<&str> = stat.get(close + 1..)?.split_whitespace().collect(); | |
| 321 | // After the name: state is field 3, utime 14 and stime 15 (1-based). | |
| 322 | let utime: u64 = rest.get(11)?.parse().ok()?; | |
| 323 | let stime: u64 = rest.get(12)?.parse().ok()?; | |
| 324 | Some((name, utime + stime)) | |
| 325 | } | |
| 326 | ||
| 327 | /// Characters read and written by a process, from `/proc/<pid>/io`. | |
| 328 | pub fn parse_proc_io(io: &str) -> u64 { | |
| 329 | io.lines() | |
| 330 | .filter_map(|line| { | |
| 331 | let (key, value) = line.split_once(':')?; | |
| 332 | matches!(key.trim(), "rchar" | "wchar").then(|| value.trim().parse::<u64>().ok()).flatten() | |
| 333 | }) | |
| 334 | .sum() | |
| 335 | } | |
| 336 | ||
| 337 | /// What the sampler remembers between readings. | |
| 338 | #[derive(Default)] | |
| 339 | struct Processes { | |
| 340 | /// CPU ticks and characters of I/O by process id, as last read. | |
| 341 | seen: BTreeMap<u32, (u64, u64)>, | |
| 342 | /// I/O of processes that have exited, so totals never go back. | |
| 343 | io_carried: u64, | |
| 344 | } | |
| 345 | ||
| 346 | fn read(path: &str) -> String { | |
| 347 | std::fs::read_to_string(path).unwrap_or_default() | |
| 348 | } | |
| 349 | ||
| 350 | impl Processes { | |
| 351 | /// Reads every process: new ones, the busiest, total I/O, and any miner. | |
| 352 | fn read(&mut self, own: u32) -> (u64, Option<String>, u64, Option<String>) { | |
| 353 | let mut now: BTreeMap<u32, (u64, u64)> = BTreeMap::new(); | |
| 354 | let mut new = 0; | |
| 355 | let mut busiest: Option<(u64, String)> = None; | |
| 356 | let mut miner = None; | |
| 357 | let Ok(entries) = std::fs::read_dir("/proc") else { | |
| 358 | return (0, None, 0, None); | |
| 359 | }; | |
| 360 | for entry in entries.flatten() { | |
| 361 | let Some(pid) = entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok()) else { | |
| 362 | continue; | |
| 363 | }; | |
| 364 | let Some((name, ticks)) = parse_proc_stat(&read(&format!("/proc/{pid}/stat"))) else { | |
| 365 | continue; | |
| 366 | }; | |
| 367 | let io = parse_proc_io(&read(&format!("/proc/{pid}/io"))); | |
| 368 | let before = self.seen.get(&pid).copied(); | |
| 369 | if before.is_none() { | |
| 370 | new += 1; | |
| 371 | } | |
| 372 | let used = ticks.saturating_sub(before.map_or(ticks, |(t, _)| t)); | |
| 373 | if pid != own && busiest.as_ref().is_none_or(|(most, _)| used > *most) { | |
| 374 | busiest = Some((used, name)); | |
| 375 | } | |
| 376 | if pid != own && miner.is_none() { | |
| 377 | let cmdline = read(&format!("/proc/{pid}/cmdline")).replace('\0', " "); | |
| 378 | miner = miner_in(&cmdline).map(|matched| format!("{matched} in `{}`", cmdline.trim())); | |
| 379 | } | |
| 380 | now.insert(pid, (ticks, io)); | |
| 381 | } | |
| 382 | for (pid, (_, io)) in &self.seen { | |
| 383 | if !now.contains_key(pid) { | |
| 384 | self.io_carried += io; | |
| 385 | } | |
| 386 | } | |
| 387 | let io_total = self.io_carried + now.values().map(|(_, io)| io).sum::<u64>(); | |
| 388 | // The first reading sees every process as new: that is not churn. | |
| 389 | let first = self.seen.is_empty(); | |
| 390 | self.seen = now; | |
| 391 | (if first { 0 } else { new }, busiest.map(|(_, name)| name), io_total, miner) | |
| 392 | } | |
| 393 | } | |
| 394 | ||
| 395 | /// Starts the sampler in the background. When it flags the sandbox it | |
| 396 | /// reports, stops every other process and exits with `EXIT_CODE`. | |
| 397 | /// Off where `/proc` cannot be read (not Linux), or with `G1T_ABUSE=off`. | |
| 398 | pub fn watch() { | |
| 399 | if std::env::var("G1T_ABUSE").as_deref() == Ok("off") || !std::path::Path::new("/proc/stat").exists() { | |
| 400 | return; | |
| 401 | } | |
| 402 | std::thread::spawn(|| { | |
| 403 | let started = std::time::Instant::now(); | |
| 404 | let own = std::process::id(); | |
| 405 | let mut detector = Detector::default(); | |
| 406 | let mut processes = Processes::default(); | |
| 407 | loop { | |
| 408 | let (cpu_busy, cpu_total) = parse_cpu(&read("/proc/stat")).unwrap_or((0, 0)); | |
| 409 | let (new_processes, busiest, process_io, miner) = processes.read(own); | |
| 410 | let sample = Sample { | |
| 411 | at: started.elapsed().as_secs(), | |
| 412 | cpu_busy, | |
| 413 | cpu_total, | |
| 414 | io_bytes: parse_disks(&read("/proc/diskstats")) + process_io, | |
| 415 | net_bytes: parse_net(&read("/proc/net/dev")), | |
| 416 | new_processes, | |
| 417 | activity: ACTIVITY.load(Ordering::Relaxed), | |
| 418 | busiest, | |
| 419 | miner, | |
| 420 | }; | |
| 421 | if let Some(verdict) = detector.observe(sample) { | |
| 422 | stop(&verdict, own); | |
| 423 | } | |
| 424 | std::thread::sleep(Duration::from_secs(SAMPLE_SECONDS)); | |
| 425 | } | |
| 426 | }); | |
| 427 | } | |
| 428 | ||
| 429 | /// Reports the verdict, kills everything else and exits. | |
| 430 | fn stop(verdict: &Verdict, own: u32) -> ! { | |
| 431 | eprintln!("g1t-runner: stopped for unusual CPU use: {}", serde_json::to_string(verdict).unwrap_or_default()); | |
| 432 | let _ = ureq::post(REPORT_URL) | |
| 433 | .timeout(Duration::from_secs(10)) | |
| 434 | .send_json(serde_json::json!({ "verdict": verdict })); | |
| 435 | if let Ok(entries) = std::fs::read_dir("/proc") { | |
| 436 | let pids: Vec<String> = entries | |
| 437 | .flatten() | |
| 438 | .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok())) | |
| 439 | .filter(|pid| *pid != own && *pid != 1) | |
| 440 | .map(|pid| pid.to_string()) | |
| 441 | .collect(); | |
| 442 | if !pids.is_empty() { | |
| 443 | // As root where the sandbox still can; as itself otherwise. | |
| 444 | let as_root = std::process::Command::new("sudo") | |
| 445 | .args(["-n", "kill", "-9"]) | |
| 446 | .args(&pids) | |
| 447 | .stdin(std::process::Stdio::null()) | |
| 448 | .stderr(std::process::Stdio::null()) | |
| 449 | .status() | |
| 450 | .is_ok_and(|status| status.success()); | |
| 451 | if !as_root { | |
| 452 | let _ = std::process::Command::new("kill").arg("-9").args(&pids).status(); | |
| 453 | } | |
| 454 | } | |
| 455 | } | |
| 456 | std::process::exit(EXIT_CODE) | |
| 457 | } | |
| 458 | ||
| 459 | #[cfg(test)] | |
| 460 | mod tests { | |
| 461 | use super::*; | |
| 462 | ||
| 463 | /// A sandbox whose CPU runs at `cpu` (0 to 1), with this much I/O, | |
| 464 | /// network and process churn each sample, sampled for `minutes`. | |
| 465 | fn run(minutes: u64, cpu: f64, io_per_sample: u64, net_per_sample: u64, new_per_sample: u64, busiest: &str, active: bool) -> Option<Verdict> { | |
| 466 | let mut detector = Detector::default(); | |
| 467 | let mut sample = Sample::default(); | |
| 468 | let mut verdict = None; | |
| 469 | for i in 0..=(minutes * 60 / SAMPLE_SECONDS) { | |
| 470 | sample.at = i * SAMPLE_SECONDS; | |
| 471 | sample.cpu_total += 1000; | |
| 472 | sample.cpu_busy += (cpu * 1000.0) as u64; | |
| 473 | sample.io_bytes += io_per_sample; | |
| 474 | sample.net_bytes += net_per_sample; | |
| 475 | sample.new_processes = if i == 0 { 0 } else { new_per_sample }; | |
| 476 | if active { | |
| 477 | sample.activity += 1; | |
| 478 | } | |
| 479 | sample.busiest = Some(busiest.to_owned()); | |
| 480 | if verdict.is_none() { | |
| 481 | verdict = detector.observe(sample.clone()); | |
| 482 | } | |
| 483 | } | |
| 484 | verdict | |
| 485 | } | |
| 486 | ||
| 487 | #[test] | |
| 488 | fn a_pinned_quiet_process_is_flagged_after_ten_minutes() { | |
| 489 | assert!(run(9, 0.99, 1_000, 500, 0, "kworker", false).is_none()); | |
| 490 | let verdict = run(11, 0.99, 1_000, 500, 0, "kworker", false).expect("flagged"); | |
| 491 | assert_eq!(verdict.reason, "cpu"); | |
| 492 | assert!(verdict.cpu > 0.95); | |
| 493 | assert!(verdict.window_seconds >= WINDOW_SECONDS); | |
| 494 | } | |
| 495 | ||
| 496 | #[test] | |
| 497 | fn a_compile_that_reads_and_writes_is_not_flagged() { | |
| 498 | // 30 MB of I/O a sample: a build writing objects. | |
| 499 | assert!(run(30, 1.0, 30 * 1024 * 1024, 0, 0, "unknown", false).is_none()); | |
| 500 | } | |
| 501 | ||
| 502 | #[test] | |
| 503 | fn a_test_suite_that_starts_processes_is_not_flagged() { | |
| 504 | assert!(run(30, 1.0, 1_000, 0, 3, "unknown", false).is_none()); | |
| 505 | } | |
| 506 | ||
| 507 | #[test] | |
| 508 | fn a_compiler_in_code_generation_is_not_flagged() { | |
| 509 | assert!(run(30, 1.0, 0, 0, 0, "rustc", false).is_none()); | |
| 510 | assert!(run(30, 1.0, 0, 0, 0, "cc1plus", false).is_none()); | |
| 511 | } | |
| 512 | ||
| 513 | #[test] | |
| 514 | fn an_agent_that_keeps_working_is_not_flagged() { | |
| 515 | assert!(run(30, 1.0, 0, 0, 0, "unknown", true).is_none()); | |
| 516 | } | |
| 517 | ||
| 518 | #[test] | |
| 519 | fn half_a_cpu_is_not_pinned() { | |
| 520 | assert!(run(30, 0.6, 0, 0, 0, "unknown", false).is_none()); | |
| 521 | } | |
| 522 | ||
| 523 | #[test] | |
| 524 | fn a_chatty_process_is_not_quiet() { | |
| 525 | // 1 MB a sample on the network: a download or a server, not a | |
| 526 | // miner whose pool the allowlist refused. | |
| 527 | assert!(run(30, 1.0, 0, 1024 * 1024, 0, "unknown", false).is_none()); | |
| 528 | } | |
| 529 | ||
| 530 | #[test] | |
| 531 | fn one_dip_does_not_save_a_miner() { | |
| 532 | let mut detector = Detector::default(); | |
| 533 | let mut sample = Sample::default(); | |
| 534 | let mut flagged = false; | |
| 535 | for i in 0..=22 { | |
| 536 | sample.at = i * SAMPLE_SECONDS; | |
| 537 | sample.cpu_total += 1000; | |
| 538 | sample.cpu_busy += if i == 5 { 300 } else { 990 }; | |
| 539 | flagged |= detector.observe(sample.clone()).is_some(); | |
| 540 | } | |
| 541 | assert!(flagged); | |
| 542 | } | |
| 543 | ||
| 544 | #[test] | |
| 545 | fn a_miner_seen_by_name_is_flagged_at_once() { | |
| 546 | let mut detector = Detector::default(); | |
| 547 | let verdict = detector | |
| 548 | .observe(Sample { miner: Some("xmrig".to_owned()), ..Sample::default() }) | |
| 549 | .expect("flagged"); | |
| 550 | assert_eq!(verdict.reason, "miner"); | |
| 551 | } | |
| 552 | ||
| 553 | #[test] | |
| 554 | fn miners_are_known_by_name_argument_and_pool() { | |
| 555 | assert_eq!(miner_in("./xmrig -o pool:3333"), Some("xmrig")); | |
| 556 | assert_eq!(miner_in("wget https://github.com/xmrig/xmrig/releases/x.tar.gz"), Some("xmrig")); | |
| 557 | assert_eq!(miner_in("./a.out -o stratum+tcp://pool:4444 -u wallet"), Some("stratum+tcp://")); | |
| 558 | assert_eq!(miner_in("./run --donate-level 1"), Some("--donate-level")); | |
| 559 | assert_eq!(miner_in("./m -o gulf.moneroocean.stream:10128"), Some("moneroocean.stream")); | |
| 560 | assert_eq!(miner_in("nohup /tmp/t-rex -a kawpow"), Some("t-rex")); | |
| 561 | assert_eq!(miner_in("CPUMINER --algo=sha256d"), Some("cpuminer")); | |
| 562 | } | |
| 563 | ||
| 564 | #[test] | |
| 565 | fn ordinary_commands_are_not_miners() { | |
| 566 | for command in [ | |
| 567 | "cargo build --release", | |
| 568 | "npm test", | |
| 569 | "git log --oneline", | |
| 570 | "cat docs/t-rex.md", | |
| 571 | "grep -r stratum src/", | |
| 572 | "python3 -m pytest -k mining_model", | |
| 573 | "go test ./...", | |
| 574 | "rg gminer_config", | |
| 575 | ] { | |
| 576 | assert_eq!(miner_in(command), None, "{command}"); | |
| 577 | } | |
| 578 | } | |
| 579 | ||
| 580 | #[test] | |
| 581 | fn proc_files_read_as_the_kernel_writes_them() { | |
| 582 | let stat = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 100 0 50 800 50 0 0 0 0 0\n"; | |
| 583 | assert_eq!(parse_cpu(stat), Some((150, 1000))); | |
| 584 | let dev = "Inter-| Receive\n face |bytes\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 2 0 0 0 0 0 0 500 3 0 0 0 0 0 0\n"; | |
| 585 | assert_eq!(parse_net(dev), 1500); | |
| 586 | let disks = " 8 0 sda 10 0 100 0 5 0 50 0 0 0 0\n 8 1 sda1 10 0 100 0 5 0 50 0 0 0 0\n 7 0 loop0 1 0 8 0 0 0 0 0 0 0 0\n 259 0 nvme0n1 1 0 2 0 1 0 2 0 0 0 0\n 259 1 nvme0n1p1 1 0 2 0 1 0 2 0 0 0 0\n"; | |
| 587 | assert_eq!(parse_disks(disks), (150 + 4) * 512); | |
| 588 | let proc_stat = "42 (my (odd) name) R 1 42 42 0 -1 4194304 100 0 0 0 700 300 0 0 20 0 1 0 1000"; | |
| 589 | assert_eq!(parse_proc_stat(proc_stat), Some(("my (odd) name".to_owned(), 1000))); | |
| 590 | assert_eq!(parse_proc_io("rchar: 100\nwchar: 50\nsyscr: 3\nread_bytes: 4096\n"), 150); | |
| 591 | } | |
| 592 | } |