# Module Development Reference for maintainers and contributors writing new Rustsploit modules. --- ## How Modules Are Discovered Rustsploit uses an `inventory`-based compile-time registry — no `build.rs`, no codegen file, no central match table: 1. **Each module file ends with `crate::register_native_module!(...)`** — a macro that expands to a unique `__ModuleImpl` struct + `impl Module` + an `inventory::submit!` block. 2. **At binary startup, `inventory::iter::` walks the registry** collected at link time. `crate::module::registered()` returns every `ModuleEntry`; `find(path)` looks up by `category/name` or short leaf name. 3. **Shell / CLI / API / MCP all resolve modules through `commands::run_module`**, which calls `module::find(...)` and then `scheduler::run(...)`. Single dispatcher. 4. **Mass-scan fan-out is universal** — `Target::Cidr` / `Multi` / `File` / `Random` is fanned out by `scheduler::run`. Modules only ever see `Target::Single` inside their `run` body. 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. --- ## Code Rules - **No dead code.** All code must be intentional and used. Do not leave unused functions, imports, or variables. - **No `unsafe` blocks.** Do not use `unsafe` Rust anywhere in this codebase. --- ## Project Code Layout ```text 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 ``` --- ## Required Module Signature Two shapes are accepted. New modules should use the **native shape**. ### Native shape (preferred) ```rust 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 { 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: "".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 check - `register_native_module!(Cat::X, "name", has_check)` — legacy, with check - `register_native_module!(Cat::X, "name", native)` — native, no check - `register_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`. ### Legacy shape (existing modules) ```rust 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()`. --- ## Migrating from legacy to native Mechanical recipe per file: 1. Add the imports — `use crate::module::{Finding, FindingKind, ModuleCtx, ModuleOutcome};`. 2. Change `pub async fn run(target: &str) -> Result<()>` to `pub async fn run(ctx: &ModuleCtx) -> Result`. 3. At the top of the body: `let target = ctx.target.as_single().unwrap_or("");`. Mass scan still works — the scheduler fans out `Cidr/Multi/File/Random` into per-host `Target::Single` before invoking `run`, so `as_single()` is `Some(...)` for every legitimate call. `unwrap_or("")` matches the legacy `target: &str` semantic; downstream `cfg_prompt_*` / `normalize_target` will surface a real error if the empty string actually leaks through. 4. Optionally migrate `check`: `pub async fn check(target: &str) -> CheckResult` → `pub async fn check(ctx: &ModuleCtx) -> CheckResult`, with the same `as_single()` pattern. 5. Replace `Ok(())` with `Ok(outcome)` (declare `let mut outcome = ModuleOutcome::ok();` near the top). 6. At each "found something" stdout site (vuln marker, recovered credential, open port banner), push a `Finding` of the right kind. 7. Update the registration line — append `, native` (and keep `, has_check` if applicable) so the registration becomes `register_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 finding - `src/modules/scanners/sample_scanner.rs` — Banner findings (HTTP/HTTPS) - `src/modules/creds/generic/sample_cred_check.rs` — Credential finding with `data` JSON --- ## Optional Module Functions Modules can optionally provide metadata and vulnerability check functions: ### Module Info (`info`) ```rust 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`. ### Vulnerability Check (`check`) — historical > **Rustsploit is exploitation-only.** The dedicated `check()` / `CheckResult` > non-destructive-verification subsystem was removed; modules run an exploit and > report findings, and you should **not** reintroduce a check phase. The > `check`-related material in this and the next few sections is retained for > readers maintaining older modules — author new work against `run(&ModuleCtx)` > and emit `Finding`s. The `register_native_module!` `has_check` arm and the > `check` shell command / `POST /api/check` endpoint are vestigial and may be > retired. ```rust 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)), } } ``` ### Auto-Store Credentials and Loot Modules can auto-store discovered data: ```rust // 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")); ``` --- ## Adding a New Module — Checklist 1. **Choose a location** under `src/modules/{exploits,scanners,creds,osint,plugins}`. Use subfolders for vendor families (e.g., `exploits/cisco/`). 2. **Create the `.rs` file** with `pub fn info()` + `pub async fn run(...)` (native or legacy shape — see above) and end the file with `crate::register_native_module!(Category::X, "name"[, native][, has_check]);`. 3. **Register in `mod.rs`** — add `pub mod your_module;` to the sibling `mod.rs` so the compiler links the file. Without this the `inventory::submit!` block never reaches the binary and the module is silently un-dispatchable. There is no `build.rs` and no central match table — the registry is collected at link time from every `register_native_module!` invocation. 4. **Run `cargo build`** — the new module appears in `--list-modules` and is reachable through every front-end (CLI `-m`, shell `use`, `/api/run`, MCP `module.run`). 5. **Regenerate the catalog** (optional) — `cargo run -- --gen-module-catalog > docs/Module-Catalog.md` walks the live registry and rewrites the catalog. --- ## Module Skeleton (native shape) ```rust 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 { 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; inside `run` you only ever see `Target::Single`. `as_single()` returning `None` is a programming error, not user input. - `ctx.options.get_or("key", default)` is the typed escape from string parsing. Operators set values via shell `set port 8080`, CLI `-o port=8080`, or the API `options` map — all routed through `ModuleOptions`. - 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 call `cred_store::store_credential` or `workspace::add_note` yourself in native modules — emit a `Finding` instead. ### Legacy skeleton (existing modules) ```rust 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. --- ## Output Conventions | 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. --- ## Async I/O Guidelines - Prefer `reqwest`, `tokio::net`, `tokio::process` for async work. - Wrap synchronous blocking calls with `tokio::task::spawn_blocking` (see the SSH module for reference). - For concurrency: - `tokio::sync::Semaphore` (wrapped in `Arc`) for async modules. - `threadpool` + `crossbeam-channel` for synchronous protocols (Telnet, POP3). --- ## Error Handling Bubble up errors using `anyhow::Context` so the shell/CLI surface meaningful messages: ```rust .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. --- ## Cancellation Long-running modules MUST honor cancellation so `kill ` from the shell or `DELETE /api/jobs/` from the API actually stops the work. The cancellation token is per-`RunContext` and is triggered automatically when a job is killed. ```rust 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`: ```rust 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. --- ## Lifecycle Hooks Native modules can override three optional hooks on the `Module` trait. The scheduler runs them in a fixed order around every CLI/API invocation: ```text pre_check → (per-host) check / run → cleanup ↑ ↑ once, before fan-out once, after fan-out ``` - `pre_check(&ModuleCtx)` — validate `ctx.options` once before fan-out so a `/16` scan 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. `outcome` is 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: ```rust 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. ## Capabilities `fn capabilities(&self) -> Capabilities` advertises what the module needs / promises: ```rust 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 Limits & Rate Limiter `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`): ```text global RPS → per-module RPS → per-target RPS ``` Native modules call `ctx.rate_limit(target_host).await` once per round trip: ```rust 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 (the `LIMITER` singleton in `src/rate_limit.rs`). - `module_rps` — default cap per module-type bucket; overridable per module via `module_rps:` (e.g. `module_rps:scanners/cors_reflection_scanner`). - `target_rps` — cap per `(module, target_host)` bucket. Because `LIMITER` is a `Lazy>`, the same budget applies across concurrent scheduler invocations — two parallel runs share one global bucket. ## Structured Findings 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: ```rust 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 by `commands::run_module` - `ModuleFinished { module, target, success }` — auto-emitted on return - `HostUp { host }` - `ServiceDetected { host, port, service, version: Option }` - `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). --- ## Network Wrappers & Source Port All TCP/UDP connections must go through the framework's network wrappers so `setg source_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: ```rust // 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. --- ## Target-Specific Filenames When a module writes output files (results, configs, payloads), include the target in the filename to avoid clobbering under concurrent mass scan: ```rust let safe = target.replace(['/', ':', '.', '[', ']'], "_"); let path = format!("results_{}.txt", safe); ``` For temp directories, use per-invocation isolation: ```rust let work_dir = std::env::temp_dir().join( format!("rsploit_module_{:08x}", rand::rng().random::()) ); ``` --- ## Batch Mode 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: ```rust 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. ### Interactive REPLs and local-only modules 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: ```rust if crate::utils::is_batch_mode() { anyhow::bail!("Interactive REPL not supported in mass-scan mode."); } ``` --- ## Wordlists & Resources 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`](Utilities-Helpers.md). ## Service / TLS Fingerprinting Helpers 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/HTTP `Server:` header) and it returns structured fields (`service.product` / `.version` / `.vendor`, `os.product`, `service.cpe23`). `scanners/service_scanner` already 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 raw `TcpStream`. The reference consumer is `scanners/jarm_scan`. Parsing is fully bounds-checked and degrades to the all-zero JARM hash on a down host / TLS alert / truncated response. ## Per-Run Output Auto-Save Console / CLI module runs auto-append all of their output (stdout + stderr, captured through the `mprintln!` / `meprintln!` routing) to `~/.rustsploit/loot/ 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. --- ## Framework-Level Multi-Target Dispatch `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 than `ipv6_max_hosts` (default 2³² hosts) and prompts above `warn_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`, or `random` → `Target::Random`. Capped at `max_random_hosts` (default 10 000) and skips ranges in `crate::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. --- ## 0.0.0.0/0 Internet-Wide Scanning `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. --- ## Current Cleanup Work 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 legacy `pub async fn run(target: &str)` shape behind the macro. Bodies are being ported one file at a time to `pub async fn run(ctx: &ModuleCtx) -> Result` so findings flow into LootStore / Workspace / events instead of stdout. Migration recipe is in _Migrating from legacy to native_ above. - **Compiler warning sweep.** `cargo build` currently surfaces ~84 warnings — mostly unused imports left behind by mid-migration scanner / exploit modules (`FindingKind`, `Finding`, `cfg_prompt_*` helpers, leftover mass-scan constants like `EXCLUDED_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 `_var` renames. The grep policy is zero suppression attributes in `src/`. - **Bad error-handling patterns.** `grep -rn 'map_err(|e| anyhow!('` and `grep -rn 'let _ ='` periodically — both usually hide a real propagation path. Prefer `.with_context(|| "...")?` over re-wrapping with `anyhow!`, and replace `let _ = ...` with the explicit `if let Err(e) = ... { tracing::warn!(...); }` pattern when the failure really is recoverable. - **Wordlist consolidation.** Module-level `WORDLIST.lines()` / `include_str!` blocks are being moved into `crate::utils::wordlist` so every brute-forcer reads through the same loader (with caching, size caps, and the `--strict-wordlist` toggle). The checksum-pinned catalog is now seeded with 6 SecLists entries (2026-06-13) — new brute-forcers should `wordlist::resolve(name)` a catalog list rather than embed one. - **Helper consolidation.** TLS helpers in `src/native/async_tls.rs`, `read_async_capped` / `DEFAULT_BODY_CAP` in `src/utils/network.rs`, and the `cancellation_token()` accessor in `src/context.rs` are the canonical entry points. Modules (notably `http2_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`, plus `osint/cname_chain` and `osint/jwks_inspector`. They auto-appear in `--list-modules` and the catalog; follow-up bug fixes are tracked under the per-module sections of `docs/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::RwLock` on the tokio path (M3), and the L1–L4 path-validation cleanups remain. P0 items (PQ rekey deadlock, SSRF bypass) are tracked in `docs/Legacy.md` § _Out of scope_ and live on a separate hardening workstream.