Release: V-1.1 - Memory Safety & Nuclei queue fix

This commit is contained in:
paimon
2026-06-19 14:00:18 +02:00
parent 5646fae9a3
commit 5cdcf62f95
13 changed files with 414 additions and 73 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ lazy_static = "1.5.0"
serde_json = "1.0.140"
which = "7.0.3"
num_cpus = "1.16.0"
nix = "0.30.1"
nix = { version = "0.30.1", features = ["resource"] }
libc = "0.2"
if-addrs = "0.13.4"
tokio-util = "0.7.15"
+10 -4
View File
@@ -7,6 +7,7 @@ use tokio::{
};
use regex::Regex;
use crate::workflow::{MASSCAN_BLOCK_HOOKS, MASSCAN_STATUS, dispatch_event, Hook};
use crate::workflow::resource_limits::{apply_memory_limit, get_memory_limit_config, memory_kill_suffix, MemoryLimitKind};
use crate::sql::ProjectContext;
use crate::utilities::{print_enum, tag_debug, tag_info};
use super::utils::guess_subnet_str;
@@ -86,10 +87,11 @@ pub async fn masscan_discovery_non_interactive(
}
}
let mut child = masscan_cmd
masscan_cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
.stderr(Stdio::piped());
apply_memory_limit(&mut masscan_cmd, get_memory_limit_config().get_limit(MemoryLimitKind::Masscan));
let mut child = masscan_cmd.spawn()?;
let mut out_buf = BufReader::new(child.stdout.take().unwrap());
let mut err_buf = BufReader::new(child.stderr.take().unwrap());
@@ -276,7 +278,11 @@ pub async fn masscan_discovery_non_interactive(
}
let _ = child.kill().await;
let _ = child.wait().await;
if let Ok(status) = child.wait().await {
if !status.success() {
tag_debug(&format!("masscan exited {:?}{}", status, memory_kill_suffix(&status)));
}
}
*MASSCAN_STATUS.write().unwrap() = None;
tag_info("Masscan discovery complete.");
+17 -4
View File
@@ -7,8 +7,14 @@ use tokio::{
use regex::Regex;
use crate::sql::ProjectContext;
use crate::utilities::print_enum;
use crate::workflow::resource_limits::{apply_memory_limit, get_memory_limit_config, looks_like_memory_limit_kill, MemoryLimitKind};
use super::utils::guess_subnet_str;
// No memory ceiling applied to the `ip addr show` / `ip route show` queries in this
// file (interface_explorer, below): they're one-shot, read-only, not influenced by
// untrusted input, and have no plausible path to large memory use — a conscious
// decision, not an oversight.
// Context: Network interface discovery for reconnaissance operations
// Operation: Analyzes selected interface to discover IPs, gateways, and subnets from routing tables
pub async fn interface_explorer(
@@ -170,13 +176,17 @@ pub async fn enumerate_arp_scan(
let _ = tx.send(format!("[INFO] Starting ARP scan on interface: {}", iface));
}
let mut child = Command::new("sudo")
.arg("arp-scan")
let mut cmd = Command::new("sudo");
cmd.arg("arp-scan")
.arg("--interface").arg(&iface)
.arg("--localnet")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
.stderr(Stdio::piped());
// Limit survives sudo's setuid/exec transition — rlimits are a process
// property inherited across exec, not tied to credentials, so this caps
// the real privileged arp-scan child, not just the sudo wrapper.
apply_memory_limit(&mut cmd, get_memory_limit_config().get_limit(MemoryLimitKind::ArpScan));
let mut child = cmd.spawn()?;
let mut out_reader = BufReader::new(child.stdout.take().unwrap()).lines();
let mut err_reader = BufReader::new(child.stderr.take().unwrap()).lines();
@@ -219,6 +229,9 @@ pub async fn enumerate_arp_scan(
let _ = tx.send("[INFO] ARP scan completed successfully".to_string());
} else {
let _ = tx.send("[ERROR] ARP scan failed - check output above for details".to_string());
if looks_like_memory_limit_kill(&exit_status) {
let _ = tx.send("[ERROR] Possible memory-limit kill — raise the ARP Scan memory ceiling in Settings if this scan is legitimate".to_string());
}
let _ = tx.send("[INFO] Common issues: interface not compatible, missing sudo permissions, or arp-scan not installed".to_string());
}
let _ = tx.send("[INFO] Task will remain visible for 10 seconds...".to_string());
+13 -10
View File
@@ -9,6 +9,7 @@ use crate::tag::{automatic_tag, TriggerContext};
use crate::sql::ProjectContext;
use crate::utilities::{print_enum, tag_debug, tag_info};
use crate::workflow::{complete_port_scan_workflow, handle_nmap_timeout, dispatch_event, Hook};
use crate::workflow::resource_limits::{apply_memory_limit, get_memory_limit_config, memory_kill_suffix, MemoryLimitKind};
// Context: Complete TCP port scanning for comprehensive host enumeration
// Operation: Executes nmap full port scan (-p-) with timeout handling and port discovery
@@ -26,8 +27,8 @@ pub async fn nmap_full_port_detection_scan(
// Full scan: port discovery only. No -oX — we don't need XML here.
// Only the sV scan (nmap_service_scan) writes to nmap_xml / nmap_scan.
let mut child = Command::new("nmap")
.arg("-p").arg("-")
let mut cmd = Command::new("nmap");
cmd.arg("-p").arg("-")
.arg("-Pn")
.arg("--open")
.arg("-v")
@@ -36,8 +37,9 @@ pub async fn nmap_full_port_detection_scan(
.arg("--max-retries").arg("2")
.arg(ip)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?;
.stderr(std::process::Stdio::null());
apply_memory_limit(&mut cmd, get_memory_limit_config().get_limit(MemoryLimitKind::NmapFullTcp));
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut lines = BufReader::new(stdout).lines();
@@ -63,7 +65,7 @@ pub async fn nmap_full_port_detection_scan(
let status = child.wait().await?;
if !status.success() {
tag_debug(&format!("nmap_full_port_detection_scan on {} exited {:?}", ip, status));
tag_debug(&format!("nmap_full_port_detection_scan on {} exited {:?}{}", ip, status, memory_kill_suffix(&status)));
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
@@ -118,8 +120,8 @@ pub async fn nmap_service_scan(
"svc"
);
let mut child = Command::new("nmap")
.arg("-sC")
let mut cmd = Command::new("nmap");
cmd.arg("-sC")
.arg("-sV")
.arg("-Pn")
.arg("-T4")
@@ -131,8 +133,9 @@ pub async fn nmap_service_scan(
.arg("-oX").arg(&xml_path_svc)
.arg(ip)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?;
.stderr(std::process::Stdio::null());
apply_memory_limit(&mut cmd, get_memory_limit_config().get_limit(MemoryLimitKind::NmapService));
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut lines = BufReader::new(stdout).lines();
@@ -153,7 +156,7 @@ pub async fn nmap_service_scan(
let status = child.wait().await?;
if !status.success() {
tag_debug(&format!("nmap_service_scan on {} exited {:?}", ip, status));
tag_debug(&format!("nmap_service_scan on {} exited {:?}{}", ip, status, memory_kill_suffix(&status)));
}
if !port_list.is_empty() {
+22 -6
View File
@@ -6,9 +6,15 @@ use tokio::{
};
use regex::Regex;
use crate::sql::ProjectContext;
use crate::utilities::{tag_info};
use crate::utilities::{tag_debug, tag_info};
use crate::workflow::resource_limits::{apply_memory_limit, get_memory_limit_config, memory_kill_suffix, MemoryLimitKind};
use super::utils::guess_subnet_str;
// No memory ceiling on the `ip link show` check or the `sudo timeout 5s tcpdump
// -c 3` connectivity test below: both are short-lived and already bounded by
// their own wall-clock/packet-count limits, so neither can accumulate the kind
// of unbounded memory the long-running listener spawn (further down) can.
// Context: Passive network traffic monitoring for stealth reconnaissance
// Operation: Uses tcpdump to capture mDNS, LLMNR, NetBIOS, SSDP traffic and discover hosts/hostnames
pub async fn passive_listener_enhanced(
@@ -89,16 +95,21 @@ pub async fn passive_listener_enhanced(
}
}
let mut child = Command::new("sudo")
.arg("tcpdump")
let mut cmd = Command::new("sudo");
cmd.arg("tcpdump")
.arg("-n")
.arg("-l")
.arg("-v")
.arg("-i").arg(&iface)
.arg("(udp port 5355 or udp port 137 or udp port 5353 or udp port 1900 or udp port 53 or udp port 67 or udp port 68)")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
.stderr(Stdio::piped());
// Limit survives sudo's setuid/exec transition, same as enumerate_arp_scan.
// Sized to tolerate indefinite runtime (this listener runs until cancelled)
// since tcpdump here only filters/prints — no -w capture file — so its own
// memory profile stays small regardless of how long it runs.
apply_memory_limit(&mut cmd, get_memory_limit_config().get_limit(MemoryLimitKind::Tcpdump));
let mut child = cmd.spawn()?;
let mut out_reader = BufReader::new(child.stdout.take().unwrap()).lines();
let mut err_reader = BufReader::new(child.stderr.take().unwrap()).lines();
@@ -194,7 +205,12 @@ pub async fn passive_listener_enhanced(
}
let _ = child.kill().await;
if let Ok(status) = child.wait().await {
if !status.success() {
tag_debug(&format!("passive_listener_enhanced tcpdump exited {:?}{}", status, memory_kill_suffix(&status)));
}
}
if let Some(tx) = &raw_tx {
let _ = tx.send("".to_string());
let _ = tx.send("[INFO] Passive listener shutting down...".to_string());
+12 -4
View File
@@ -9,6 +9,7 @@ use ipnetwork::IpNetwork;
use crate::sql::ProjectContext;
use crate::utilities::{print_enum, tag_debug, tag_info};
use crate::workflow::{dispatch_event, Hook};
use crate::workflow::resource_limits::{apply_memory_limit, get_memory_limit_config, memory_kill_suffix, MemoryLimitKind};
use super::utils::guess_subnet_str;
// Context: Initial subnet reconnaissance with targeted port scanning
@@ -59,7 +60,9 @@ pub async fn subnet_phase1_scan(
cmd1.arg("--exclude").arg(&excl_list.join(","));
}
let mut child1 = cmd1.arg(&subnet).stdout(Stdio::piped()).spawn()?;
cmd1.arg(&subnet).stdout(Stdio::piped());
apply_memory_limit(&mut cmd1, get_memory_limit_config().get_limit(MemoryLimitKind::NmapSubnetQuick));
let mut child1 = cmd1.spawn()?;
let stdout1 = child1.stdout.take().unwrap();
let mut lines1 = BufReader::new(stdout1).lines();
let re_open = Regex::new(r"(?i)open port \d+/tcp on ([0-9.]+)")?;
@@ -83,7 +86,7 @@ pub async fn subnet_phase1_scan(
let status1 = child1.wait().await?;
if !status1.success() {
tag_debug(&format!("Phase 1 nmap on {} exited {:?}", subnet, status1));
tag_debug(&format!("Phase 1 nmap on {} exited {:?}{}", subnet, status1, memory_kill_suffix(&status1)));
}
ctx.update_phase1_hosts(&subnet, &phase1_hosts).await?;
@@ -134,7 +137,12 @@ pub async fn subnet_phase2_scan(
cmd2.arg("--exclude").arg(&all_excl.join(","));
}
let mut child2 = cmd2.arg(&subnet).stdout(Stdio::piped()).spawn()?;
cmd2.arg(&subnet).stdout(Stdio::piped());
// Reuses NmapFullTcp's ceiling: same workload shape as the single-host
// full -p- scan, applied here as defense-in-depth (phase2 is structurally
// unreachable for large CIDRs today via subnet_phase1_scan's /24 gate).
apply_memory_limit(&mut cmd2, get_memory_limit_config().get_limit(MemoryLimitKind::NmapFullTcp));
let mut child2 = cmd2.spawn()?;
let stdout2 = child2.stdout.take().unwrap();
let mut lines2 = BufReader::new(stdout2).lines();
let re_full = Regex::new(r"(?i)open port (\d+)/tcp on ([0-9.]+)")?;
@@ -162,7 +170,7 @@ pub async fn subnet_phase2_scan(
let status2 = child2.wait().await?;
if !status2.success() {
tag_debug(&format!("Phase 2 nmap on {} exited {:?}", subnet, status2));
tag_debug(&format!("Phase 2 nmap on {} exited {:?}{}", subnet, status2, memory_kill_suffix(&status2)));
}
ctx.mark_subnet_phase_complete(&subnet, "subnet_phase2").await?;
+26 -2
View File
@@ -5,6 +5,7 @@ use tokio::sync::mpsc::UnboundedSender;
use headless_chrome::{Browser, LaunchOptions};
use headless_chrome::protocol::cdp::Page;
use crate::utilities::{tag_debug};
use crate::workflow::resource_limits::{apply_memory_limit_to_pid, get_memory_limit_config, MemoryLimitKind};
use crate::VERBOSE;
use super::config::{ScreenshotConfig, ScreenshotFormat, ScreenshotResult};
@@ -54,15 +55,38 @@ impl BrowserManager {
OsStr::new("--ignore-certificate-errors"),
OsStr::new("--ignore-ssl-errors"),
OsStr::new("--ignore-certificate-errors-spki-list"),
// Collapses Chrome into a single process. Trades crash isolation
// (a renderer crash/hang can now take down the whole instance,
// including other concurrently-open tabs) for the ability to
// apply one PID-scoped memory ceiling that actually bounds the
// entire browser — without this, the lightweight main process
// is the only thing a parent-PID limit would cap, while
// unbounded separate renderer/GPU child processes do the real
// memory consumption.
OsStr::new("--single-process"),
])
.build()
.map_err(|e| format!("Failed to build launch options: {}", e))?;
let browser = Browser::new(launch_options)
.map_err(|e| format!("Failed to launch browser: {}", e))?;
// Retroactive cap via prlimit64 — setrlimit/pre_exec aren't available
// here since headless_chrome owns the spawn internally. Non-fatal:
// log and proceed unlimited on failure rather than fail the whole
// screenshot operation, since this is defense-in-depth on a process
// ROFMAD doesn't fully control, not a hard precondition.
// Worst case aggregate across SCREENSHOT_SEMAPHORE_LIMIT (5) concurrent
// browsers: 5 x this limit.
if let Some(pid) = browser.get_process_id() {
let limit_mb = get_memory_limit_config().get_limit(MemoryLimitKind::ScreenshotChrome);
if let Err(e) = apply_memory_limit_to_pid(pid, limit_mb) {
tag_debug(&format!("Failed to apply memory limit to Chrome pid {}: {}", pid, e));
}
}
self.browser = Some(browser);
self.temp_profile_dir = Some(temp_dir);
self.temp_profile_dir = Some(temp_dir);
}
Ok(self.browser.as_ref().unwrap())
+28 -6
View File
@@ -17,6 +17,7 @@ pub enum OperationType {
SubnetPhase1,
SubnetPhase2,
HttpDiscovery,
Nuclei,
}
impl OperationType {
@@ -30,6 +31,7 @@ impl OperationType {
OperationType::SubnetPhase1 => 400,
OperationType::SubnetPhase2 => 1200,
OperationType::HttpDiscovery => 75,
OperationType::Nuclei => 50,
}
}
@@ -41,6 +43,7 @@ impl OperationType {
OperationType::SubnetPhase1,
OperationType::SubnetPhase2,
OperationType::HttpDiscovery,
OperationType::Nuclei,
]
}
}
@@ -54,6 +57,7 @@ impl std::fmt::Display for OperationType {
OperationType::SubnetPhase1 => write!(f, "Subnet Phase 1"),
OperationType::SubnetPhase2 => write!(f, "Subnet Phase 2"),
OperationType::HttpDiscovery => write!(f, "HTTP Discovery"),
OperationType::Nuclei => write!(f, "Nuclei"),
}
}
}
@@ -67,12 +71,24 @@ pub struct ConcurrencyConfig {
pub subnet_phase1: usize,
pub subnet_phase2: usize,
pub http_discovery: usize,
/// Kept at 1 — nuclei is as resource-demanding as masscan, so only one
/// instance is allowed to run at a time across the whole project. This
/// used to be a standalone semaphore (NUCLEI_SEMAPHORE); it's modeled as
/// a regular OperationType now so default_spawn's queued path applies to
/// it (acquired before the per-task timeout clock starts), instead of
/// being acquired inside the timed factory closure.
#[serde(default = "default_nuclei_limit")]
pub nuclei: usize,
/// Minimum delay (ms) enforced between successive task launches, to spread out
/// bursts (e.g. when masscan discovery fires hooks for many hosts at once).
#[serde(default = "default_launch_stagger_ms")]
pub launch_stagger_ms: u64,
}
fn default_nuclei_limit() -> usize {
1
}
fn default_launch_stagger_ms() -> u64 {
2000
}
@@ -86,6 +102,7 @@ impl ConcurrencyConfig {
subnet_phase1: 10,
subnet_phase2: 3,
http_discovery: 10,
nuclei: 1,
launch_stagger_ms: 2000,
}
}
@@ -98,6 +115,7 @@ impl ConcurrencyConfig {
subnet_phase1: 1,
subnet_phase2: 1,
http_discovery: 10,
nuclei: 1,
launch_stagger_ms: 0,
}
}
@@ -110,6 +128,7 @@ impl ConcurrencyConfig {
OperationType::SubnetPhase1 => self.subnet_phase1,
OperationType::SubnetPhase2 => self.subnet_phase2,
OperationType::HttpDiscovery => self.http_discovery,
OperationType::Nuclei => self.nuclei,
}
}
@@ -121,6 +140,7 @@ impl ConcurrencyConfig {
OperationType::SubnetPhase1 => self.subnet_phase1 = value,
OperationType::SubnetPhase2 => self.subnet_phase2 = value,
OperationType::HttpDiscovery => self.http_discovery = value,
OperationType::Nuclei => self.nuclei = value,
}
}
}
@@ -272,7 +292,9 @@ impl ConcurrencyManager {
// Context: Builds the grouped boxes shown in the Dashboard/Queue concurrency widgets —
// Nmap (NmapFullTcp + NmapService) and Subnet (SubnetPhase1 + SubnetPhase2) are merged
// into single boxes, and Nuclei/Screenshot reflect their dedicated global semaphores
// into single boxes; Nuclei is its own OperationType (so it's gated by default_spawn's
// pre-timeout permit acquisition, not a semaphore acquired inside a timed factory); and
// Screenshot still reflects its own dedicated global semaphore
fn build_display_groups(stats: &HashMap<OperationType, OperationStats>) -> Vec<ConcurrencyDisplayGroup> {
let get = |op: OperationType| stats.get(&op).cloned().unwrap_or(OperationStats {
total_limit: 0, in_use: 0, available: 0, estimated_tcp_connections: 0,
@@ -284,8 +306,8 @@ fn build_display_groups(stats: &HashMap<OperationType, OperationStats>) -> Vec<C
let subnet1 = get(OperationType::SubnetPhase1);
let subnet2 = get(OperationType::SubnetPhase2);
let http_discovery = get(OperationType::HttpDiscovery);
let nuclei = get(OperationType::Nuclei);
let nuclei_available = super::constants::NUCLEI_SEMAPHORE.available_permits();
let screenshot_available = super::constants::SCREENSHOT_SEMAPHORE.available_permits();
vec![
@@ -311,8 +333,8 @@ fn build_display_groups(stats: &HashMap<OperationType, OperationStats>) -> Vec<C
},
ConcurrencyDisplayGroup {
name: "Nuclei".to_string(),
in_use: super::constants::NUCLEI_SEMAPHORE_LIMIT.saturating_sub(nuclei_available),
total_limit: super::constants::NUCLEI_SEMAPHORE_LIMIT,
in_use: nuclei.in_use,
total_limit: nuclei.total_limit,
},
ConcurrencyDisplayGroup {
name: "Screenshot".to_string(),
@@ -382,9 +404,9 @@ pub fn initialize_concurrency(config: ConcurrencyConfig) {
let manager = ConcurrencyManager::new(config.clone());
*CONCURRENCY_MANAGER.write().unwrap() = Some(Arc::new(manager));
crate::utilities::tag_info(&format!(
"Concurrency initialized: Global:{} Full_TCP:{} Service:{} Sub_P1:{} Sub_P2:{} HTTP:{}",
"Concurrency initialized: Global:{} Full_TCP:{} Service:{} Sub_P1:{} Sub_P2:{} HTTP:{} Nuclei:{}",
config.global, config.nmap_full_tcp, config.nmap_service,
config.subnet_phase1, config.subnet_phase2, config.http_discovery
config.subnet_phase1, config.subnet_phase2, config.http_discovery, config.nuclei
));
}
+1 -12
View File
@@ -1,6 +1,6 @@
// src/workflow/constants.rs
use std::collections::{HashSet, HashMap};
use std::sync::{Arc, Mutex, RwLock, atomic::{AtomicBool, AtomicUsize}};
use std::sync::{Mutex, RwLock, atomic::{AtomicBool, AtomicUsize}};
use once_cell::sync::Lazy;
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
@@ -33,17 +33,6 @@ pub static TASK_SEMAPHORE: Lazy<Semaphore> = Lazy::new(|| Semaphore::new(MAX_CON
pub const SCREENSHOT_SEMAPHORE_LIMIT: usize = 5;
pub static SCREENSHOT_SEMAPHORE: Lazy<Semaphore> = Lazy::new(|| Semaphore::new(SCREENSHOT_SEMAPHORE_LIMIT));
/// Global nuclei slot limiter — nuclei is as resource-demanding as masscan, so only
/// one instance is allowed to run at a time across the whole project.
pub const NUCLEI_SEMAPHORE_LIMIT: usize = 1;
pub static NUCLEI_SEMAPHORE: Lazy<Semaphore> = Lazy::new(|| Semaphore::new(NUCLEI_SEMAPHORE_LIMIT));
/// Per-host nuclei semaphores — at most 2 concurrent nuclei per IP.
/// Arc is cloned before any await so the Mutex is never held across an await point.
/// All tasks for the same IP share the same Arc<Semaphore> → FIFO order via Tokio's fair semaphore.
pub static NUCLEI_HOST_SEMAPHORES: Lazy<Mutex<HashMap<String, Arc<Semaphore>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
/// Count of queued tasks
pub static QUEUED_TASKS: Lazy<AtomicUsize> = Lazy::new(|| AtomicUsize::new(0));
+25 -4
View File
@@ -7,6 +7,7 @@ use std::time::Instant;
use crate::http::HttpServiceInfo;
use crate::sql::ProjectContext;
use crate::utilities::{print_action, print_cmd, tag_info};
use super::resource_limits::{apply_memory_limit, get_memory_limit_config, looks_like_memory_limit_kill, MemoryLimitKind};
use super::types::SpecificActionStep;
use super::placeholders::replace_placeholders;
@@ -108,12 +109,27 @@ pub async fn execute_simple_command(
cancel_token: CancellationToken,
tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<(i32, String), Box<dyn std::error::Error + Send + Sync>> {
let mut child = Command::new("sh")
.arg("-c")
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
.stderr(std::process::Stdio::piped());
// Single chokepoint for every current and future user-defined workflow
// action, including shell pipelines — both sides of a pipe inherit this
// limit since they're forked/exec'd by this same "sh -c" process.
//
// Note for Go-based custom tools (subfinder, httpx, dnsx, naabu, katana,
// etc.): RLIMIT_AS alone can be too tight for Go binaries, since the Go
// runtime reserves virtual address space at startup independent of real
// usage and crashes hard rather than degrading gracefully if it's capped
// too low (see the GOMEMLIMIT comments in nuclei.rs for the full
// explanation — nuclei hit exactly this). ROFMAD can't generically detect
// "this command launches a Go binary," so the default ceiling here is
// sized generously to tolerate that; an operator wanting tighter control
// over a specific Go tool's memory can set GOMEMLIMIT inline in their own
// action command, e.g. `GOMEMLIMIT=512MiB subfinder ...`.
apply_memory_limit(&mut cmd, get_memory_limit_config().get_limit(MemoryLimitKind::WorkflowAction));
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
@@ -154,6 +170,11 @@ pub async fn execute_simple_command(
tokio::select! {
output = drain => {
let status = child.wait().await?;
if looks_like_memory_limit_kill(&status) {
let note = "[CMD] Possible memory-limit kill — raise the Workflow Action memory ceiling in Settings if this command is legitimate".to_string();
let _ = tx.send(note.clone());
return Ok((status.code().unwrap_or(-1), format!("{}\n{}", output, note)));
}
Ok((status.code().unwrap_or(-1), output))
}
_ = tokio::time::sleep(timeout) => {
+1
View File
@@ -22,6 +22,7 @@ pub mod execution;
pub mod host_queue;
pub mod nuclei;
pub mod placeholders;
pub mod resource_limits;
pub mod resume;
pub mod screenshots;
pub mod shutdown;
+40 -20
View File
@@ -5,7 +5,8 @@ use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_util::sync::CancellationToken;
use tokio::sync::mpsc::UnboundedSender;
use super::concurrency::OperationType;
use super::constants::{NUCLEI_SEMAPHORE, NUCLEI_HOST_SEMAPHORES, GLOBAL_CANCEL_TOKEN};
use super::constants::GLOBAL_CANCEL_TOKEN;
use super::resource_limits::{apply_memory_limit, get_memory_limit_config, memory_kill_suffix, MemoryLimitKind};
use super::spawning::default_spawn;
use crate::sql::ProjectContext;
use crate::sql::nuclei::NucleiFinding;
@@ -46,11 +47,23 @@ pub async fn run_nuclei_template_update() -> Result<(), String> {
use std::process::Stdio;
tag_info("Updating nuclei templates...");
let child_result = tokio::process::Command::new("nuclei")
.args(["-update-templates", "-silent"])
let update_limit_mb = get_memory_limit_config().get_limit(MemoryLimitKind::NucleiUpdate);
let mut cmd = tokio::process::Command::new("nuclei");
cmd.args(["-update-templates", "-silent"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
// nuclei is a Go binary — the Go runtime reserves virtual address space at
// startup regardless of workload, which RLIMIT_AS (below) counts against and
// which Go treats as an unrecoverable "cannot allocate memory" crash rather
// than a graceful malloc failure. GOMEMLIMIT is Go's own GC-cooperative soft
// memory target and is what actually keeps nuclei's real usage bounded here.
.env("GOMEMLIMIT", format!("{}MiB", update_limit_mb));
// RLIMIT_AS is kept only as a generous hard backstop (4x the soft target) —
// defense in depth in case GOMEMLIMIT is ever unavailable/insufficient, sized
// well above what the Go runtime's own startup reservations need so it doesn't
// fight normal operation the way a tight RLIMIT_AS alone does.
apply_memory_limit(&mut cmd, update_limit_mb.saturating_mul(4));
let child_result = cmd.spawn();
let mut child = match child_result {
Ok(c) => c,
@@ -82,7 +95,7 @@ pub async fn run_nuclei_template_update() -> Result<(), String> {
tag_info("Nuclei template update complete");
Ok(())
}
Ok(Ok(status)) => Err(format!("nuclei -update-templates exited with {:?}", status)),
Ok(Ok(status)) => Err(format!("nuclei -update-templates exited with {:?}{}", status, memory_kill_suffix(&status))),
Ok(Err(e)) => Err(format!("nuclei -update-templates error: {}", e)),
Err(_) => {
child.kill().await.ok();
@@ -141,6 +154,13 @@ pub async fn ensure_nuclei_templates_ready() -> Result<(), String> {
}
}
// Context: Dispatch a single nuclei scan, used both by the per-service "Run Nuclei"
// button and the bulk "Run Nuclei on All HTTP Services" action (one call per service).
// Operation: Queues the scan under OperationType::Nuclei (global limit 1) so
// default_spawn's queued path waits for a slot *before* starting the per-task timeout
// clock — bulk batches now show up under Queued Tasks while waiting instead of
// misleadingly under Active, and the 300s timeout bounds only the actual scan, not the
// time spent waiting behind other queued scans for the single global nuclei slot.
pub fn dispatch_nuclei_scan(ip: &str, port: &str, protocol: &str, ctx: &ProjectContext) {
let ip_c = ip.to_string();
let port_c = port.to_string();
@@ -150,20 +170,11 @@ pub fn dispatch_nuclei_scan(ip: &str, port: &str, protocol: &str, ctx: &ProjectC
default_spawn(
"nuclei_scan",
ip_c.clone(),
OperationType::Global,
true,
OperationType::Nuclei,
false,
move |raw_tx, cancel_token| {
let ctx2 = ctx2.clone();
async move {
// Clone Arc before any await — Mutex released immediately
let host_sem = {
let mut map = NUCLEI_HOST_SEMAPHORES.lock().unwrap();
map.entry(ip_c.clone())
.or_insert_with(|| std::sync::Arc::new(tokio::sync::Semaphore::new(2)))
.clone()
};
let _host_permit = host_sem.acquire().await.ok();
let _global_permit = NUCLEI_SEMAPHORE.acquire().await.ok();
run_nuclei_scan(&ctx2, &ip_c, &port_c, &protocol_c, raw_tx, cancel_token).await;
}
},
@@ -187,11 +198,16 @@ async fn run_nuclei_scan(
let _ = tx.send(format!("$ nuclei -target {} -j -silent -duc -as -pt http -tlsi -timeout 10", target));
}
let child_result = tokio::process::Command::new("nuclei")
.args(["-target", &target, "-j", "-silent", "-duc", "-as", "-pt", "http", "-tlsi", "-timeout", "10"])
let scan_limit_mb = get_memory_limit_config().get_limit(MemoryLimitKind::NucleiScan);
let mut cmd = tokio::process::Command::new("nuclei");
cmd.args(["-target", &target, "-j", "-silent", "-duc", "-as", "-pt", "http", "-tlsi", "-timeout", "10"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
// See run_nuclei_template_update for why GOMEMLIMIT (Go's own GC-cooperative
// soft target) is the real control here, and RLIMIT_AS below is just a backstop.
.env("GOMEMLIMIT", format!("{}MiB", scan_limit_mb));
apply_memory_limit(&mut cmd, scan_limit_mb.saturating_mul(4));
let child_result = cmd.spawn();
let mut child = match child_result {
Ok(c) => c,
@@ -311,7 +327,11 @@ async fn run_nuclei_scan(
}
stderr_task.await.ok();
child.wait().await.ok();
if let Ok(status) = child.wait().await {
if !status.success() {
tag_info(&format!("nuclei scan on {}:{} exited {:?}{}", ip, port, status, memory_kill_suffix(&status)));
}
}
let count = findings.len();
tag_info(&format!("Nuclei scan complete: {}:{}{} finding(s)", ip, port, count));
+217
View File
@@ -0,0 +1,217 @@
// src/workflow/resource_limits.rs
//
// OS-level virtual-memory ceilings for every subprocess ROFMAD spawns.
// Enforced via RLIMIT_AS, set in the child before exec (pre_exec) — this is
// a single syscall at process startup, enforced for free by the kernel's own
// memory-accounting path for the lifetime of the process. No userspace
// polling/watchdog is used, so there is no ongoing runtime cost.
//
// Context: a manually-run nmap scan against a /16 network ballooned to 29GB
// RSS / 58GB virtual on a 30GB-RAM machine. Inside a container it was cleanly
// OOM-killed by the container's cgroup; the same scan run directly on a bare
// host had no containment and drove the system into swap-thrashing instead.
// ROFMAD itself applied no memory limit to any subprocess it spawns — these
// ceilings exist so that guarantee no longer depends on whether a container
// happens to be present.
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
use std::sync::RwLock;
use nix::sys::resource::{setrlimit, Resource};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
/// One variant per *workload shape* spawned by ROFMAD, not one per
/// workflow::concurrency::OperationType — memory usage tracks what a tool is
/// actually doing (full port scan vs. quick vs. service detection), not which
/// orchestration phase dispatched it. `subnet_phase2_scan` (the `nmap -p-`
/// full scan over a subnet) intentionally reuses `NmapFullTcp`'s ceiling
/// rather than getting its own variant: same workload shape as a single-host
/// full scan, just defense-in-depth since it's structurally unreachable for
/// large CIDRs today (see subnet_phase1_scan's /24 gate).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MemoryLimitKind {
NmapFullTcp,
NmapService,
NmapSubnetQuick,
Masscan,
ArpScan,
Tcpdump,
WorkflowAction,
NucleiUpdate,
NucleiScan,
ScreenshotChrome,
}
impl std::fmt::Display for MemoryLimitKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MemoryLimitKind::NmapFullTcp => write!(f, "Nmap Full TCP"),
MemoryLimitKind::NmapService => write!(f, "Nmap Service"),
MemoryLimitKind::NmapSubnetQuick => write!(f, "Nmap Subnet Quick"),
MemoryLimitKind::Masscan => write!(f, "Masscan"),
MemoryLimitKind::ArpScan => write!(f, "ARP Scan"),
MemoryLimitKind::Tcpdump => write!(f, "Tcpdump"),
MemoryLimitKind::WorkflowAction => write!(f, "Workflow Action"),
MemoryLimitKind::NucleiUpdate => write!(f, "Nuclei Update"),
MemoryLimitKind::NucleiScan => write!(f, "Nuclei Scan"),
MemoryLimitKind::ScreenshotChrome => write!(f, "Screenshot (Chrome)"),
}
}
}
/// Per-kind virtual-memory ceilings, in megabytes. All defaults target the
/// single-host / single-target invocation shape ROFMAD's call sites
/// actually use today — none of them scan an unbounded target list per
/// process. A host with an unusually large number of open ports under
/// `-sC -sV` could approach the high end of `nmap_service_mb`; operators
/// hitting that edge case should raise that specific limit rather than
/// have ROFMAD guess a higher global default that weakens the common case.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryLimitConfig {
pub nmap_full_tcp_mb: u64,
pub nmap_service_mb: u64,
pub nmap_subnet_quick_mb: u64,
pub masscan_mb: u64,
pub arp_scan_mb: u64,
pub tcpdump_mb: u64,
pub workflow_action_mb: u64,
pub nuclei_update_mb: u64,
pub nuclei_scan_mb: u64,
pub screenshot_chrome_mb: u64,
}
impl MemoryLimitConfig {
pub fn default_preset() -> Self {
Self {
nmap_full_tcp_mb: 1024,
nmap_service_mb: 1536,
nmap_subnet_quick_mb: 1536,
masscan_mb: 2048,
arp_scan_mb: 256,
tcpdump_mb: 512,
// Higher than the scan-range tools above on purpose: this is the catch-all
// RLIMIT_AS ceiling for arbitrary user-defined "sh -c" workflow actions
// (execute_simple_command in execution.rs), and operators commonly chain in
// other Go-based recon tools here (subfinder, httpx, dnsx, naabu, katana —
// the same ecosystem as nuclei). Go runtimes reserve large virtual address
// space at startup independent of real usage and crash hard (not gracefully)
// if RLIMIT_AS is too tight — see the GOMEMLIMIT comments in nuclei.rs for the
// full explanation. WorkflowAction invocations are dispatched per-host/
// per-target by the workflow engine (not handed an unbounded CIDR the way
// nmap/masscan can be), so the blast radius here is inherently smaller than
// the scan-range tools, making this looser default an acceptable trade.
workflow_action_mb: 4096,
nuclei_update_mb: 1024,
nuclei_scan_mb: 1024,
screenshot_chrome_mb: 1536,
}
}
pub fn get_limit(&self, kind: MemoryLimitKind) -> u64 {
match kind {
MemoryLimitKind::NmapFullTcp => self.nmap_full_tcp_mb,
MemoryLimitKind::NmapService => self.nmap_service_mb,
MemoryLimitKind::NmapSubnetQuick => self.nmap_subnet_quick_mb,
MemoryLimitKind::Masscan => self.masscan_mb,
MemoryLimitKind::ArpScan => self.arp_scan_mb,
MemoryLimitKind::Tcpdump => self.tcpdump_mb,
MemoryLimitKind::WorkflowAction => self.workflow_action_mb,
MemoryLimitKind::NucleiUpdate => self.nuclei_update_mb,
MemoryLimitKind::NucleiScan => self.nuclei_scan_mb,
MemoryLimitKind::ScreenshotChrome => self.screenshot_chrome_mb,
}
}
}
/// Global config — always holds a valid value (unlike concurrency::CONCURRENCY_MANAGER,
/// there's no "uninitialized" state to distinguish: spawn sites such as the Interface
/// Explorer's `ip` queries can run before any explicit init step, and memory ceilings
/// should apply from the first subprocess spawn onward).
///
/// Read-only for now (hardcoded `default_preset()` values) — operator
/// configurability via the Settings page is a deliberately separate,
/// lower-priority follow-up, not built in this pass. Add a setter +
/// DB-restore path here when that's implemented.
static MEMORY_LIMIT_CONFIG: Lazy<RwLock<MemoryLimitConfig>> =
Lazy::new(|| RwLock::new(MemoryLimitConfig::default_preset()));
pub fn get_memory_limit_config() -> MemoryLimitConfig {
MEMORY_LIMIT_CONFIG.read().unwrap().clone()
}
/// Caps a not-yet-spawned child's virtual address space (RLIMIT_AS) to
/// `limit_mb` megabytes. Call this on the `Command` after building it but
/// before `.spawn()`. Soft == hard limit — RLIMIT_AS isn't something a
/// process can usefully catch and lower itself in response to, so there's
/// no reason to leave a soft/hard grace gap.
///
/// Survives `sudo`/`sh -c` exec transitions: rlimits are a property of the
/// process, inherited across `fork`/`exec` by that process's own children,
/// and preserved across `sudo`'s setuid transition — so applying this to a
/// `sudo <tool>` or `sh -c "<command>"` Command correctly caps the real
/// privileged/child process, not just the wrapper.
pub fn apply_memory_limit(cmd: &mut tokio::process::Command, limit_mb: u64) {
let bytes = limit_mb.saturating_mul(1024 * 1024);
// Safety: pre_exec runs in the forked child between fork() and exec(),
// so the closure must be async-signal-safe — it does nothing but this
// one setrlimit() call (no allocation, no locking, no formatting).
unsafe {
cmd.pre_exec(move || {
let _ = setrlimit(Resource::RLIMIT_AS, bytes, bytes);
Ok(())
});
}
}
/// Retroactively caps an already-running process's virtual address space via
/// the Linux-specific prlimit64(2) syscall. Used only for headless Chrome,
/// where ROFMAD never builds the Command itself (the headless_chrome crate
/// owns the spawn) — `setrlimit`/`pre_exec` aren't available for a process
/// that's already running, so this is the fallback. Non-fatal by design:
/// the caller should log a failure and proceed unlimited rather than fail
/// the whole screenshot operation, since this is a defense-in-depth retrofit
/// on a process ROFMAD doesn't fully control, not a hard precondition.
pub fn apply_memory_limit_to_pid(pid: u32, limit_mb: u64) -> Result<(), String> {
let bytes = limit_mb.saturating_mul(1024 * 1024);
let new_limit = libc::rlimit64 {
rlim_cur: bytes,
rlim_max: bytes,
};
// Safety: prlimit64 with a non-null new_limit and null old_limit simply
// reads `new_limit` and writes nothing back; `pid` is a plain integer.
let rc = unsafe {
libc::prlimit64(
pid as libc::pid_t,
libc::RLIMIT_AS,
&new_limit,
std::ptr::null_mut(),
)
};
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error().to_string())
}
}
/// Heuristic only — these signals can also occur for unrelated crashes/bugs
/// in the target tool, so callers should log this as "possible"/"likely",
/// never as a certainty. Detecting an RLIMIT_AS kill with full accuracy
/// isn't possible from the parent's view (malloc/mmap failure inside the
/// child can surface as SIGSEGV, SIGABRT, SIGBUS, or even a clean non-zero
/// exit if the tool checks allocation failures itself) — this heuristic
/// exists purely so an operator can tell "I capped this too low" apart from
/// "this tool has an unrelated bug" at a glance in the logs.
pub fn looks_like_memory_limit_kill(status: &ExitStatus) -> bool {
matches!(status.signal(), Some(libc::SIGSEGV | libc::SIGABRT | libc::SIGBUS))
}
/// Formats a one-line suffix for existing exit-status log messages, e.g.
/// `tag_debug(&format!("... exited {:?}{}", status, memory_kill_suffix(&status)))`.
pub fn memory_kill_suffix(status: &ExitStatus) -> String {
if looks_like_memory_limit_kill(status) {
format!(" (possible memory-limit kill — signal {:?})", status.signal())
} else {
String::new()
}
}