Reference for maintainers and contributors writing new Rustsploit modules.
Rustsploit uses an inventory-based compile-time registry — no build.rs, no
codegen file, no central match table:
- Each module file ends with
crate::register_native_module!(...)— a macro that expands to a unique__ModuleImplstruct +impl Module+ aninventory::submit!block. - At binary startup,
inventory::iter::<ModuleEntry>walks the registry collected at link time.crate::module::registered()returns everyModuleEntry;find(path)looks up bycategory/nameor short leaf name. - Shell / CLI / API / MCP all resolve modules through
commands::run_module, which callsmodule::find(...)and thenscheduler::run(...). Single dispatcher. - Mass-scan fan-out is universal —
Target::Cidr/Multi/File/Randomis fanned out byscheduler::run. Modules only ever seeTarget::Singleinside theirrunbody.
Because the registry is collected at compile time, there is no runtime
discovery cost and no drift — if you forget the register_native_module!
line, the module simply isn't reachable.
- No dead code. All code must be intentional and used. Do not leave unused functions, imports, or variables.
- No
unsafeblocks. Do not useunsafeRust anywhere in this codebase.
rustsploit/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point — CLI or shell mode, input validation
│ ├── cli.rs # Clap-based CLI parser and dispatcher
│ ├── shell.rs # Interactive shell loop + UX helpers
│ ├── api.rs # REST + WebSocket API server — PQ encryption, rate limiting
│ ├── ws.rs # PQ-encrypted WebSocket transport (/pq/ws)
│ ├── config.rs # Global config and target validation
│ ├── module_info.rs # ModuleInfo, CheckResult, ModuleRank types
│ ├── global_options.rs # Persistent global options (setg/unsetg)
│ ├── cred_store.rs # Credential store (JSON persistence)
│ ├── spool.rs # Console output logging
│ ├── workspace.rs # Host/service tracking + workspaces
│ ├── loot.rs # Loot/evidence management
│ ├── export.rs # JSON/CSV/summary report export
│ ├── jobs.rs # Background job management
│ ├── mcp/
│ │ ├── mod.rs # MCP server entry point (--mcp flag)
│ │ ├── server.rs # rmcp ServerHandler adapter (official MCP SDK owns transport)
│ │ └── tools.rs # 29 MCP tool implementations
│ ├── commands/
│ │ └── mod.rs # Single dispatcher: module::find → scheduler::run
│ ├── module.rs # Module trait, ModuleCtx, register_native_module! macro
│ ├── scheduler.rs # Universal mass-scan fan-out, finding routing, checkpoint/resume
│ ├── modules/
│ │ ├── exploits/ # Exploit modules
│ │ ├── scanners/ # Scanner modules
│ │ ├── creds/ # Credential modules
│ │ ├── osint/ # OSINT modules
│ │ └── plugins/ # Plugin modules
│ ├── native/ # Native integrations
│ │ ├── mod.rs
│ │ ├── rdp.rs # Native RDP auth (X.224, TLS, CredSSP/NTLM)
│ │ ├── payload_engine.rs # Payload encoding/generation
│ │ ├── url_encoding.rs # URL encoding utilities
│ │ └── async_tls.rs # Async TLS helpers
│ └── utils/ # Shared helpers (directory module)
│ ├── mod.rs # Re-exports
│ ├── prompt.rs # Config-aware prompts (cfg_prompt_*)
│ ├── sanitize.rs # Input validation, length limits
│ ├── target.rs # Target normalization (IPv4/IPv6/CIDR/hostname)
│ ├── network.rs # HTTP client builders, TCP/UDP connect helpers
│ ├── privilege.rs # Root privilege check (require_root)
│ └── modules.rs # Module discovery helpers
├── docs/ # This wiki
├── lists/ # Wordlists and data files
└── README.md # Product overview
Two shapes are accepted. New modules should use the native shape.
use anyhow::{Context, Result};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{CheckResult, ModuleInfo, ModuleRank};
pub fn info() -> ModuleInfo { /* ... */ }
pub async fn check(ctx: &ModuleCtx) -> CheckResult { /* optional */ }
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx.target.as_single().unwrap_or("");
let mut outcome = ModuleOutcome::ok();
// ... probe target ...
if vulnerable {
outcome.findings.push(Finding {
target: target.to_string(),
kind: FindingKind::Vulnerable,
message: "<short marker>".to_string(),
data: None,
});
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Exploits, "your_module", native, has_check);The macro form selects the body shape:
register_native_module!(Cat::X, "name")— legacy, no checkregister_native_module!(Cat::X, "name", has_check)— legacy, with checkregister_native_module!(Cat::X, "name", native)— native, no checkregister_native_module!(Cat::X, "name", native, has_check)— native, with check
The scheduler routes outcome.findings into LootStore (Credential),
Workspace notes (Vulnerable), and the events bus (every kind). No manual
plumbing — see route_findings in src/scheduler.rs.
pub async fn run(target: &str) -> anyhow::Result<()> { /* ... */ Ok(()) }
pub async fn check(target: &str) -> CheckResult { /* optional */ }
crate::register_native_module!(crate::module::Category::Exploits, "your_module", has_check);Stdout-only via mprintln!. Findings are not emitted — the macro discards
the Result<()> into ModuleOutcome::ok().
Mechanical recipe per file:
- Add the imports —
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};. - Change
pub async fn run(target: &str) -> Result<()>topub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>. - At the top of the body:
let target = ctx.target.as_single().unwrap_or("");. Mass scan still works — the scheduler fans outCidr/Multi/File/Randominto per-hostTarget::Singlebefore invokingrun, soas_single()isSome(...)for every legitimate call.unwrap_or("")matches the legacytarget: &strsemantic; downstreamcfg_prompt_*/normalize_targetwill surface a real error if the empty string actually leaks through. - Optionally migrate
check:pub async fn check(target: &str) -> CheckResult→pub async fn check(ctx: &ModuleCtx) -> CheckResult, with the sameas_single()pattern. - Replace
Ok(())withOk(outcome)(declarelet mut outcome = ModuleOutcome::ok();near the top). - At each "found something" stdout site (vuln marker, recovered credential,
open port banner), push a
Findingof the right kind. - Update the registration line — append
, native(and keep, has_checkif applicable) so the registration becomesregister_native_module!(Cat::X, "name", native[, has_check]);.
ctx.options.get_or("port", 22u16) replaces ad-hoc parsing of legacy
cfg_prompt_* answers when you need typed access; the legacy
cfg_prompt_* helpers continue to work because the macro keeps the
RUN_CONTEXT task-local in scope.
Reference migrations:
src/modules/exploits/sample_exploit.rs— has_check + Vulnerable findingsrc/modules/scanners/sample_scanner.rs— Banner findings (HTTP/HTTPS)src/modules/creds/generic/sample_cred_check.rs— Credential finding withdataJSON
Modules can optionally provide metadata and vulnerability check functions:
use crate::module_info::{ModuleInfo, ModuleRank};
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "My Exploit Module".to_string(),
description: "Exploits CVE-XXXX-YYYY in FooBar device firmware.".to_string(),
authors: vec!["Your Name".to_string()],
references: vec![
"CVE-XXXX-YYYY".to_string(),
"https://example.com/advisory".to_string(),
],
disclosure_date: Some("2025-01-15".to_string()),
rank: ModuleRank::Good,
default_port: Some(8080),
}
}The info shell command and GET /api/module/{category}/{name} endpoint display this metadata.
Rank values: Excellent (reliable, no crash risk), Great, Good (default), Normal, Low, Manual.
Rustsploit is exploitation-only. The dedicated
check()/CheckResultnon-destructive-verification subsystem was removed; modules run an exploit and report findings, and you should not reintroduce a check phase. Thecheck-related material in this and the next few sections is retained for readers maintaining older modules — author new work againstrun(&ModuleCtx)and emitFindings. Theregister_native_module!has_checkarm and thecheckshell command /POST /api/checkendpoint are vestigial and may be retired.
use crate::module_info::CheckResult;
pub async fn check(target: &str) -> CheckResult {
// (historical) — do NOT add a check phase to new modules
match test_vulnerability(target).await {
Ok(true) => CheckResult::Vulnerable("Version 1.2.3 is affected".to_string()),
Ok(false) => CheckResult::NotVulnerable("Patched version detected".to_string()),
Err(e) => CheckResult::Error(format!("Check failed: {}", e)),
}
}Modules can auto-store discovered data:
// Store a found credential
crate::cred_store::store_credential(host, port, "ssh", username, password,
crate::cred_store::CredType::Password, "creds/generic/ssh_bruteforce");
// Store loot (config file, hash dump, etc.)
crate::loot::store_loot(host, "config", "Router config dump", data.as_bytes(), "exploits/router_rce");
// Track a discovered host/service
crate::workspace::track_host(ip, Some("router.local"), Some("Linux 4.x"));
crate::workspace::track_service(ip, 22, "tcp", "ssh", Some("OpenSSH 8.9"));- Choose a location under
src/modules/{exploits,scanners,creds,osint,plugins}. Use subfolders for vendor families (e.g.,exploits/cisco/). - Create the
.rsfile withpub fn info()+pub async fn run(...)(native or legacy shape — see above) and end the file withcrate::register_native_module!(Category::X, "name"[, native][, has_check]);. - Register in
mod.rs— addpub mod your_module;to the siblingmod.rsso the compiler links the file. Without this theinventory::submit!block never reaches the binary and the module is silently un-dispatchable. There is nobuild.rsand no central match table — the registry is collected at link time from everyregister_native_module!invocation. - Run
cargo build— the new module appears in--list-modulesand is reachable through every front-end (CLI-m, shelluse,/api/run, MCPmodule.run). - Regenerate the catalog (optional) —
cargo run -- --gen-module-catalog > docs/Module-Catalog.mdwalks the live registry and rewrites the catalog.
use anyhow::{Context, Result};
use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};
use crate::module_info::{ModuleInfo, ModuleRank};
use crate::utils::network::{build_http_client_with, HttpClientOpts};
use std::time::Duration;
pub fn info() -> ModuleInfo {
ModuleInfo {
name: "example_status_probe".into(),
description: "Probe /status for the 'vulnerable' marker.".into(),
authors: vec!["Your Name".into()],
references: vec!["https://example.com/advisory".into()],
disclosure_date: None,
rank: ModuleRank::Good,
}
}
pub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome> {
let target = ctx
.target
.as_single()
.context("module requires a single-host target")?
.to_string();
let port: u16 = ctx.options.get_or("port", 80u16);
// Hierarchical rate limiter — global → per-module → per-target buckets.
ctx.rate_limit(&target).await;
let url = format!("http://{target}:{port}/status");
let body = build_http_client_with(Duration::from_secs(15), HttpClientOpts::permissive())?
.get(&url)
.send()
.await
.with_context(|| format!("Failed to reach {url}"))?
.text()
.await
.context("Failed to read response body")?;
let mut outcome = ModuleOutcome::ok();
if body.contains("vulnerable") {
outcome.findings.push(Finding {
target: target.clone(),
kind: FindingKind::Vulnerable,
message: format!("{target}:{port} reports vulnerable"),
data: None,
});
}
Ok(outcome)
}
crate::register_native_module!(crate::module::Category::Scanners, "example_status_probe", native, has_check);Notes:
- Mass-scan fan-out (
Cidr/File/Multi/Random) is handled by the scheduler; insiderunyou only ever seeTarget::Single.as_single()returningNoneis a programming error, not user input. ctx.options.get_or("key", default)is the typed escape from string parsing. Operators set values via shellset port 8080, CLI-o port=8080, or the APIoptionsmap — all routed throughModuleOptions.- Findings push into
outcome.findings; the scheduler routes them into LootStore (Credential), Workspace notes (Vulnerable/OpenPort/Banner/Note), and the events bus automatically. Do not callcred_store::store_credentialorworkspace::add_noteyourself in native modules — emit aFindinginstead.
use anyhow::{Context, Result};
use colored::Colorize;
use crate::utils::{normalize_target, cfg_prompt_port, cfg_prompt_yes_no};
pub async fn run(target: &str) -> Result<()> {
let target = normalize_target(target)?;
let port = cfg_prompt_port("port", "Target port", 80).await?;
let verbose = cfg_prompt_yes_no("verbose", "Verbose output?", false).await?;
crate::mprintln!("{} Checking {}:{}", "[*]".cyan(), target, port);
// ... probe ...
Ok(())
}
crate::register_native_module!(crate::module::Category::Scanners, "example", has_check);The legacy macro arms keep cfg_prompt_*, mprintln!, and is_cancelled() working
through the RUN_CONTEXT task-local that the macro installs around every call.
Findings are not emitted — the macro discards Result<()> into ModuleOutcome::ok()
and the route-findings pipeline sees nothing.
| Prefix | Color | Meaning |
|---|---|---|
[+] |
Green | Success / found |
[-] |
Red | Not found / not vulnerable |
[!] |
Yellow | Warning |
[*] |
Cyan | Info / progress |
Use .green(), .red(), .yellow(), .cyan() from the colored crate. Keep messages short and actionable.
- Prefer
reqwest,tokio::net,tokio::processfor async work. - Wrap synchronous blocking calls with
tokio::task::spawn_blocking(see the SSH module for reference). - For concurrency:
tokio::sync::Semaphore(wrapped inArc) for async modules.threadpool+crossbeam-channelfor synchronous protocols (Telnet, POP3).
Bubble up errors using anyhow::Context so the shell/CLI surface meaningful messages:
.with_context(|| format!("Failed to connect to {}", target))?No panics in module code. As of v0.4.9 the entire src/ tree is panic-free — grep finds zero .unwrap(), .expect(, panic!(, unreachable!(, unimplemented!(, or todo!(. Use ? propagation, _or(default), _or_default(), _or_else(|| ...), or explicit match { Err(e) => ... }. The CI policy is to keep that grep returning empty.
For length-checked slice conversions (a common source of historical .expect()), use try_into().map_err(|_| anyhow!("descriptive context"))? rather than .expect("length was checked") — even when the length truly was checked. Future readers shouldn't have to verify the invariant by hand.
Long-running modules MUST honor cancellation so kill <job_id> from the shell or DELETE /api/jobs/<id> from the API actually stops the work. The cancellation token is per-RunContext and is triggered automatically when a job is killed.
loop {
if crate::context::is_cancelled() {
crate::mprintln!("[!] Cancelled by user, stopping at host {}", current);
break;
}
// ... one iteration of work ...
}For tokio::select!-style code, use crate::context::cancellation_token() and select! against tok.cancelled().await:
let tok = crate::context::cancellation_token();
tokio::select! {
res = real_work() => handle(res),
_ = tok.cancelled() => {
crate::mprintln!("[!] Cancelled");
return Ok(());
}
}The framework also emits ModuleStarted and ModuleFinished events automatically around every run_module(...) call, so subscribers always see lifecycle transitions.
Native modules can override three optional hooks on the Module trait. The
scheduler runs them in a fixed order around every CLI/API invocation:
pre_check → (per-host) check / run → cleanup
↑ ↑
once, before fan-out once, after fan-out
pre_check(&ModuleCtx)— validatectx.optionsonce before fan-out so a/16scan with a missing wordlist surfaces one error instead of 65 534 identical ones. Default: succeed.cleanup(&ModuleCtx, &ModuleOutcome)— release long-lived resources (open files, persistent connections) after the whole fan-out completes or is cancelled.outcomeis the aggregate (success count + every routed finding). Default: no-op.
check(&ModuleCtx) is the existing non-destructive vulnerability check; the scheduler
exposes it via the shell check command and POST /api/check. Override
fn has_check() if your check is meaningful (the register_native_module! has_check
token sets this for you).
Tracked task spawns:
ctx.spawn(async move {
long_running_telemetry().await;
});ctx.spawn registers the join handle on the active RunContext. The scheduler
calls crate::context::abort_all_spawned() from cleanup, so cancelled or failed
runs do not leak orphan tasks. Plain tokio::spawn is still allowed but bypasses
this — only use it for genuinely fire-and-forget work that can outlive the module.
fn capabilities(&self) -> Capabilities advertises what the module needs / promises:
use crate::module::Capabilities;
fn capabilities(&self) -> Capabilities {
Capabilities {
safe_for_high_concurrency: true, // rate-limit-friendly probe
requires_root: false,
check_only: false, // run() is destructive / interactive
network: true,
}
}There is no per-module mass-scan flag — the scheduler fans out for every module
(Capabilities::native_mass_scan was removed in v0.5.1). Capabilities feed into UI
gating ("show check button", "warn that root is needed") and future scheduler
decisions; they are not load-bearing today, so default values are fine for most
modules.
scheduler::SchedulerLimits carries the per-invocation budget. Defaults are pulled
from the active tenant's global_options (set / setg in the shell):
| Field | Default | global_options key |
|---|---|---|
concurrency |
50 | concurrency |
timeout_secs |
60 | module_timeout |
max_random_hosts |
10 000 | max_random_hosts |
precheck_port |
none | port |
ipv6_max_hosts |
2³² | hard limit |
warn_threshold |
65 536 | hard limit |
honeypot_detection |
on | honeypot_detection |
Per-target deadlines are enforced with tokio::time::timeout; per-host honeypot
checks call utils::network::quick_honeypot_check and skip targets that look like
they have 11+ common ports open.
Rate limiting is hierarchical (crate::rate_limit::GlobalLimiter):
global RPS → per-module RPS → per-target RPS
Native modules call ctx.rate_limit(target_host).await once per round trip:
for cred in &candidates {
if ctx.is_cancelled() { break; }
ctx.rate_limit(&target).await; // gate every probe
try_login(&target, cred).await?;
}All tiers default to RPS = 0 (no-op). Operators tune them through global_options:
global_rps— process-wide ceiling (theLIMITERsingleton insrc/rate_limit.rs).module_rps— default cap per module-type bucket; overridable per module viamodule_rps:<category/name>(e.g.module_rps:scanners/cors_reflection_scanner).target_rps— cap per(module, target_host)bucket.
Because LIMITER is a Lazy<Arc<GlobalLimiter>>, the same budget applies across
concurrent scheduler invocations — two parallel runs share one global bucket.
Native modules emit findings via outcome.findings.push(Finding { ... }) — the
scheduler routes each one based on kind:
FindingKind |
Routed to |
|---|---|
Credential |
LootStore::store_loot (kind = "credential", payload = data JSON or message) |
Vulnerable |
Workspace::add_note |
OpenPort / Banner / Note |
Workspace::track_host + Workspace::add_note (when message non-empty) |
Every finding (regardless of kind) is also broadcast as
ModuleEvent::Finding { module, target, kind, message } on the events bus.
WebSocket subscribers (panels, MCP tooling, integrations) consume them without
grepping stdout.
Legacy modules can still emit one-off events directly:
crate::events::emit(crate::events::ModuleEvent::CredentialFound {
host: target.to_string(),
port,
service: "ssh".into(),
username: user.into(),
});Available variants (all #[non_exhaustive] — adding more is non-breaking):
ModuleStarted { module, target }— auto-emitted bycommands::run_moduleModuleFinished { module, target, success }— auto-emitted on returnHostUp { host }ServiceDetected { host, port, service, version: Option<String> }CredentialFound { host, port, service, username }LootStored { id, host, kind }
Emission is non-blocking and silently drops when there are no subscribers (the common CLI-only case).
All TCP/UDP connections must go through the framework's network wrappers so
setg source_port <port> is honoured universally — including during mass-scan
fan-out where hundreds of concurrent tasks share the same source port via
SO_REUSEADDR / SO_REUSEPORT.
| Wrapper | Use case |
|---|---|
tcp_connect_str(addr, timeout) |
Async TCP from "host:port" string |
tcp_connect_addr(addr, timeout) |
Async TCP from SocketAddr |
blocking_tcp_connect(addr, timeout) |
Sync TCP for spawn_blocking (e.g. telnet crate) |
udp_bind(Some(ip)) |
UDP socket with correct address family |
Third-party library pattern: Libraries (suppaftp, telnet) that create their own TCP connections bypass source port binding. Instead, connect through the framework wrapper and pass the pre-connected stream:
// FTP (suppaftp)
let tcp = crate::utils::network::tcp_connect_str(&addr, timeout).await?;
let ftp = AsyncFtpStream::connect_with_stream(tcp).await?;
// Telnet (telnet crate, blocking)
let tcp = crate::utils::network::blocking_tcp_connect(&sa, timeout)?;
let telnet = Telnet::from_stream(Box::new(tcp), 500);Never use TcpStream::connect(), UdpSocket::bind("0.0.0.0:0"), or
library-level connect functions (AsyncFtpStream::connect(addr),
Telnet::connect(addr)) — they bypass source port binding.
When a module writes output files (results, configs, payloads), include the target in the filename to avoid clobbering under concurrent mass scan:
let safe = target.replace(['/', ':', '.', '[', ']'], "_");
let path = format!("results_{}.txt", safe);For temp directories, use per-invocation isolation:
let work_dir = std::env::temp_dir().join(
format!("rsploit_module_{:08x}", rand::rng().random::<u32>())
);When the framework dispatches a mass-scan target (0.0.0.0, random, CIDR, file, comma-separated), it enters batch mode and fans out N concurrent module invocations against single IPs. Modules MUST gate interactive UI behind is_batch_mode() or risk N concurrent menu prints flooding the terminal:
use crate::context::is_batch_mode;
pub async fn run(target: &str) -> Result<()> {
if !is_batch_mode() {
crate::mprintln!("=== My Module ===");
crate::mprintln!("[*] Loaded {} targets", n);
}
// For menus that pick a target type (Single / Subnet / File),
// short-circuit to "Single Target" — the framework already orchestrated targets.
let mode = if is_batch_mode() {
ModeChoice::SingleTarget
} else {
// print menu, read cfg_prompt_default("mode", ...), parse
};
// For REPL-style modules, break out after one action in batch mode:
let in_batch = is_batch_mode();
loop {
let cmd = cfg_prompt_default("cmd", "exec");
do_one_action(&cmd).await?;
if in_batch { break; }
}
Ok(())
}The cached cfg_prompt_default(...) returns the same value every call, so a REPL loop reading prompts spins forever in batch mode unless you break; after one iteration. This was the v0.4.9 root cause for ~22 modules across two sweeps — see the changelog entry.
Modules with interactive REPLs (e.g. h3c_websocket_dump) or local-only
functionality (e.g. windows_dwm_cve_2026_20805) should bail immediately
in batch mode since they cannot operate meaningfully under fan-out:
if crate::utils::is_batch_mode() {
anyhow::bail!("Interactive REPL not supported in mass-scan mode.");
}Store bundled lists under lists/ and document them in lists/readme.md. Reference paths relative to the working directory.
For canonical lists, prefer the checksum-pinned resolver crate::utils::wordlist::resolve(name) over shipping a copy: it downloads + SHA-256-verifies into ~/.rustsploit/wordlists/ on first use and reuses the cache after. As of the 2026-06-13 release the catalog is seeded with 6 curated SecLists entries (passwords-top-1k, passwords-top-10k, usernames-short, web-common, web-raft-small-dirs, subdomains-top5k); wordlist::catalogue() lists every name this build knows. See Utilities-Helpers.md.
Two shared fingerprinting surfaces are available to scanner modules (added 2026-06-13):
crate::utils::recog— a Rapid7-Recog-style banner matcher. Feed it a banner (SSH/FTP/SMTP/MySQL/HTTPServer:header) and it returns structured fields (service.product/.version/.vendor,os.product,service.cpe23).scanners/service_scanneralready uses it to enrich detected versions with a product/version + CPE; new banner-reading scanners should reuse it rather than hand-rolling regex.crate::utils::tls_fingerprint— JARM (canonical 62-char hash), JA3, and JA3S over a rawTcpStream. The reference consumer isscanners/jarm_scan. Parsing is fully bounds-checked and degrades to the all-zero JARM hash on a down host / TLS alert / truncated response.
Console / CLI module runs auto-append all of their output (stdout + stderr, captured through the mprintln! / meprintln! routing) to ~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt via src/results_sink.rs (append mode, begun/ended per run in commands::run_module). You do not need to add your own "save results to file" logic for this — append mode also means a multi-host mass scan accumulates into one run file instead of racing to overwrite. API / MCP runs return their output to the caller via OUTPUT_BUFFER and are not duplicated to disk.
commands::run_module resolves the requested name through module::find and hands the
boxed Module + parsed Target to scheduler::run. The scheduler is the only place
that knows how to fan a target out — module bodies always see Target::Single.
Supported target shapes (parsed by Target::parse):
- Single host:
10.0.0.1,example.com,[2001:db8::1]:80→Target::Single. - Comma-separated list:
192.168.1.1,192.168.1.2,10.0.0.1→Target::Multi. Capped at 4 096 entries; each entry recursively re-parses (so a list of CIDRs is allowed). - CIDR subnet:
192.168.1.0/24→Target::Cidr. Refuses IPv6 ranges wider thanipv6_max_hosts(default 2³² hosts) and prompts abovewarn_threshold(default 65 536). - File-based target list: any path that resolves to an existing file →
Target::File. Blank/comment lines are skipped at fan-out time. - Random mass scan:
0.0.0.0,0.0.0.0/0, orrandom→Target::Random. Capped atmax_random_hosts(default 10 000) and skips ranges incrate::exclusions::ExclusionSet.
Every shape goes through the same pre_check → fan-out → route_findings → cleanup
pipeline (see Lifecycle Hooks). A module that handles a single host correctly
inherits subnet, list, file, and 0.0.0.0/0 scanning for free.
Target::Random (parsed from 0.0.0.0, 0.0.0.0/0, or random) fans out random
public IPs through scheduler::fanout_random, capped at SchedulerLimits::max_random_hosts.
The scheduler skips addresses that match crate::exclusions::ExclusionSet — by
default this covers bogons, RFC 1918, reserved/documentation ranges, and the public
DNS providers. The set is built from the active tenant's global_options via
crate::exclusions::shared(); operators add or remove ranges through set /
setg keys (or an exclusion file) without touching module code.
Modules do not roll their own random-IP loops or EXCLUDED_RANGES constants —
the historical utils::bruteforce::run_mass_scan + per-module MassScanConfig
pattern was removed in v0.5.1. Honeypot detection is suppressed in mass-scan mode
because the per-host probe would itself be the slow path.
Active workstreams (snapshot — see docs/Legacy.md for the running ledger):
- Native body migration. Every module is registered through
register_native_module!but most still use the legacypub async fn run(target: &str)shape behind the macro. Bodies are being ported one file at a time topub async fn run(ctx: &ModuleCtx) -> Result<ModuleOutcome>so findings flow into LootStore / Workspace / events instead of stdout. Migration recipe is in Migrating from legacy to native above. - Compiler warning sweep.
cargo buildcurrently surfaces ~84 warnings — mostly unused imports left behind by mid-migration scanner / exploit modules (FindingKind,Finding,cfg_prompt_*helpers, leftover mass-scan constants likeEXCLUDED_RANGES,generate_random_public_ip,MASS_SCAN_CONCURRENCY,DEFAULT_TIMEOUT_SECS,COMMON_TELNET_PORTS). Fix the underlying cause (delete the leftover code or wire it in) — do not paper over with#[allow(dead_code)],#[allow(unused_imports)],let _ = ..., or_varrenames. The grep policy is zero suppression attributes insrc/. - Bad error-handling patterns.
grep -rn 'map_err(|e| anyhow!('andgrep -rn 'let _ ='periodically — both usually hide a real propagation path. Prefer.with_context(|| "...")?over re-wrapping withanyhow!, and replacelet _ = ...with the explicitif let Err(e) = ... { tracing::warn!(...); }pattern when the failure really is recoverable. - Wordlist consolidation. Module-level
WORDLIST.lines()/include_str!blocks are being moved intocrate::utils::wordlistso every brute-forcer reads through the same loader (with caching, size caps, and the--strict-wordlisttoggle). The checksum-pinned catalog is now seeded with 6 SecLists entries (2026-06-13) — new brute-forcers shouldwordlist::resolve(name)a catalog list rather than embed one. - Helper consolidation. TLS helpers in
src/native/async_tls.rs,read_async_capped/DEFAULT_BODY_CAPinsrc/utils/network.rs, and thecancellation_token()accessor insrc/context.rsare the canonical entry points. Modules (notablyhttp2_rapid_reset,sshpwn_session, the DoS family) are being migrated off of their per-module reimplementations. - New scanner / OSINT modules added recently —
cors_reflection_scanner,security_headers_scanner,csp_audit_scanner,subdomain_takeover_scanner,source_map_scanner,wellknown_scanner,wp_xmlrpc_scanner,wp_user_enum,s3_bucket_scanner,m365_userenum_scanner, plusosint/cname_chainandosint/jwks_inspector. They auto-appear in--list-modulesand the catalog; follow-up bug fixes are tracked under the per-module sections ofdocs/Changelog.md. - External bug-bounty corpus.
_analysis/carries the cross-program findings index used to drive new module work (which probes earned findings, which vector classes are still untested). It is the source of truth for "where should the next scanner live"; do not edit historical reports under_analysis/, append a new finding instead. - Open audit findings (medium / low). Tenant cache eviction (M2),
std::sync::RwLockon the tokio path (M3), and the L1–L4 path-validation cleanups remain. P0 items (PQ rekey deadlock, SSRF bypass) are tracked indocs/Legacy.md§ Out of scope and live on a separate hardening workstream.