Framework hardening: retry-then-continue, panic fixes, WS/MCP bug fixes, no-swallow sweep + loud error surfacing; docs + release notes

Hardening (non-module framework files):
- Retry-then-continue: bounded per-host retry on transient failures across all 4
  mass-scan fan-outs; '10 errors -> abort sweep' softened to warn-and-continue
- Crash fixes: shell completer char-boundary guard; unreachable! -> bail!
- WS oversize-frame desync fixed (was bricking the PQ AEAD ratchet); MCP tenant
  job list/kill, out-of-range port, non-string option now correct/errored
- No silent error swallowing: swept framework files, every dropped error now
  bound + surfaced (warn for logged-only/data-loss, debug for already-propagated
  or aggregated per-host); removed _ => {} and Err(_)/|_| discards

Docs + release: README + docs/ updated for the release; RELEASE_NOTES.txt
section 6d added; new RELEASE_GITHUB.txt (GitHub release body).

Build: 0 errors, 0 warnings, 40/40 targeted tests green.
This commit is contained in:
s-b-repo
2026-06-13 02:29:12 +02:00
parent 05cfdc6d22
commit 94899781d3
25 changed files with 562 additions and 153 deletions
+8 -3
View File
@@ -1,6 +1,8 @@
# Rustsploit
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. Rustsploit ships an interactive shell, a command-line runner, and an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services.
Modular offensive tooling for embedded targets, written in Rust and inspired by RouterSploit/Metasploit. One binary exposes the same module library through **four interfaces** — an interactive shell, a command-line runner, a post-quantum-encrypted REST/WebSocket API, and an MCP server — over an ever-growing library of exploits, scanners, and credential modules for routers, cameras, appliances, and general network services, with **Recog** service fingerprinting and **JARM/JA3** TLS fingerprinting built in.
> **Latest release:** see [`RELEASE_NOTES.txt`](RELEASE_NOTES.txt) — official `rmcp` MCP SDK, Recog + JARM/JA3/JA3S fingerprinting, a SecLists wordlist catalog, per-run output auto-save, HTTP connection pooling, and mass-scan fixes.
![Rustsploit Interactive Shell Demo](https://github.com/s-b-repo/rustsploit/raw/main/preview.png)
@@ -47,12 +49,15 @@ Full documentation lives in the **[Rustsploit Wiki](docs/Home.md)**. Below is a
- **Resource scripts:** Automate workflows from files, auto-load startup scripts, save command history with `makerc`
- **Background jobs:** Run modules asynchronously with `run -j`, manage with `jobs` commands
- **Export/reporting:** Export all engagement data to JSON, CSV, or human-readable summary reports
- **Console logging:** `spool` command captures all output to file for documentation
- **Console logging & auto-save:** `spool` captures all output to a file on demand; in addition, **every console/CLI module run is auto-saved** (append mode) to `~/.rustsploit/loot/<module> <time> results.txt` — stdout and stderr both captured, multi-host sweeps accumulate into one per-run file
- **Comprehensive credential tooling:** FTP(S), SSH, Telnet, POP3(S), SMTP, IMAP, RDP, RTSP, SNMP, L2TP, MQTT, VNC, MySQL, PostgreSQL, Redis, CouchDB, Elasticsearch, Memcached, HTTP Basic, Proxy, Fortinet — with IPv6 and TLS support
- **Exploit coverage:** CVEs for VNC (LibVNC, TigerVNC, TightVNC, x11vnc), honeypots (Cowrie, Dionaea, HoneyTrap, SNARE), WAFs (SafeLine), Apache Camel, Kubernetes ingress-nginx, Commvault, MISP, Zimbra, Next.js, Vite, and 100+ more
- **Scanners & utilities:** Port scanner, ping sweep, SSDP, HTTP title grabber, DNS recursion tester, directory bruteforcer, sequential fuzzer, proxy scanner, reflect scanner, vulnerability checker
- **Service & TLS fingerprinting:** Rapid7 **Recog** banner → product/version/CPE matching folded into service detection, plus Salesforce **JARM** + **JA3/JA3S** active TLS server fingerprinting (`scanners/jarm_scan`)
- **Wordlist catalog:** checksum-pinned **SecLists** wordlists fetched + SHA-256-verified on demand into `~/.rustsploit/wordlists/` (`utils::wordlist::resolve`)
- **Performance:** shared, cached HTTP client — TLS config + connection pool reused across runs instead of rebuilt per request, with a bounded idle timeout for internet-scale sweeps
- **API server:** PQ-encrypted WebSocket transport — post-quantum cryptography, full CRUD for credentials, hosts, services, loot, jobs
- **MCP server:** 38-tool Model Context Protocol server for AI-assisted pentesting via stdio
- **MCP server:** Model Context Protocol server on the **official `rmcp` SDK (v1.7)** — 29 tools + 7 resources for AI-assisted pentesting via stdio
- **Plugin system:** Third-party modules via `src/modules/plugins/` with compile-time `inventory` self-registration and startup safety warnings
- **Security hardened:** Input validation, path traversal protection, honeypot detection, root privilege checks, spool symlink protection, memory-safe operations
- **IPv4/IPv6 ready:** Both address families work out-of-the-box across all modules
+38
View File
@@ -0,0 +1,38 @@
# Rustsploit — Fingerprinting, official MCP SDK, output auto-save + framework hardening
One binary, four interfaces (shell · CLI · PQ-encrypted API · MCP), ~389 self-registering modules. This release ports four upstream projects in, hardens the core, and fixes a batch of mass-scan and protocol bugs. All ports are permissively licensed.
## ✨ New capabilities (ports)
- **MCP server on the official `rmcp` SDK (v1.7, Apache-2.0).** Replaced the hand-rolled JSON-RPC server; all **29 tools + 7 resources** preserved, validated end-to-end. Per-call timeout and stdout-isolation kept.
- **Recog fingerprinting (Rapid7, BSD-2).** XML fingerprint DB + matcher folded into service detection → banners resolve to real product/version/CPE.
- **JARM + JA3/JA3S (Salesforce, BSD-3).** Active TLS server fingerprinting — 10 hand-crafted ClientHellos → JARM hash, plus JA3/JA3S. New `scanners/jarm_scan`.
- **SecLists wordlist catalog (MIT).** Six checksum-pinned lists fetched + SHA-256-verified on demand into `~/.rustsploit/wordlists/`.
## 🛠️ Fixes & features
- **Per-run output auto-save.** Every console/CLI run auto-appends all output (stdout+stderr) to `~/.rustsploit/loot/<module> <time> results.txt` — multi-host sweeps accumulate into one file instead of racing to overwrite.
- **Full-internet sweep cap consistency** — `0.0.0.0/0` typed directly now scans every public host (was silently capped at 10k on one code path).
- **Mass-scan pre-config** — confirmation now comes *before* the prompt-harvest, and the placeholder host is no longer scanned (new `ModuleCtx.prompt_only`).
- **`show options`** gained `scan_order` / `exclusions` / `target_rps` / `module_rps`.
- **HTTP performance** — shared, cached reqwest client (connection pool + TLS config reused across runs) with a bounded idle timeout for internet-scale sweeps.
## 🔒 Framework hardening (this release)
- **Retry-then-continue** — every per-host module dispatch (all four mass-scan fan-outs) now retries once on a transient failure (timeout / connection reset / aborted / broken pipe) then continues; a single host never aborts the run. The old "10 errors → abort the sweep" heuristic is now warn-and-continue.
- **Crash fixes** — shell completer no longer panics on a mid-UTF-8 cursor; `unreachable!` → `bail!` in the fan-outs.
- **WS frame desync [fixed]** — an oversize frame was skipped in a way that permanently desynced (bricked) the PQ AEAD ratchet; now closes cleanly.
- **MCP tenant jobs [fixed]** — `list_jobs`/`kill_job` now use the same tenant job-manager as spawn (tenant jobs were invisible/unkillable); out-of-range port and non-string option values now error instead of being silently dropped.
- **No silent error-swallowing** — swept the framework files and bound + surfaced every dropped error via `tracing`.
## 📚 Docs
README + `docs/` (Changelog, MCP-Integration, Module-Development, Utilities-Helpers, Interactive-Shell, …) updated for the release and corrected (exploitation-only; compile-time `inventory` self-registration — no `check()`/`CheckResult`, no `build.rs` indexer).
## ✅ Verification
`cargo build`: **0 errors, 0 warnings** · targeted test suites: **40 passed, 0 failed** · bad-patterns audit clean on changed files. `Cargo.lock` restored (a clean clone was unbuildable without it).
## Licenses (ported)
Recog BSD-2 · JARM/JA3/JA3S BSD-3 · rmcp Apache-2.0 · SecLists MIT · ZMap iterator Apache-2.0
+51
View File
@@ -338,6 +338,57 @@ fourth was investigated and intentionally deferred with a precise finding.
Adopting the one-line early return per module closes this fully.
================================================================================
6d. FRAMEWORK HARDENING (error-safety, retry-then-continue, wiring)
================================================================================
A two-agent audit of the framework (non-module) .rs files (it found the core
"unusually disciplined" already) plus fixes for every genuine issue surfaced:
Panic / crash paths:
* src/shell.rs — the rustyline completer sliced the input line at a raw byte
offset; a cursor landing mid-UTF-8 codepoint crashed the whole shell. Now
snaps to a char boundary first.
* src/scheduler.rs — three `unreachable!(...)` in the fan-out dispatch are now
`anyhow::bail!(...)` (internal-error surfaced, never a panic).
Retry-then-continue (transient errors retry, then the scan continues):
* New `run_host_with_retry` in scheduler.rs wraps every per-host module
dispatch across all four mass-scan fan-outs (CIDR / file / random /
sequential): a transient failure (timeout, connection reset/aborted, broken
pipe, unexpected EOF) is retried once with a short backoff; a terminal error
(closed port, "not affected") returns immediately. A single host never
aborts the loop.
* The "first 10 dispatches errored -> abort the whole sweep" heuristic in the
random + sequential sweeps is softened to a one-time warning that KEEPS
GOING (Ctrl+C still stops it) — error -> retry -> continue, not auto-abort.
* Confirmed already-correct (left as-is): throttle back-off, tcp_connect
address-iteration, bruteforce per-host error tolerance, job catch_unwind.
Wiring / protocol bugs:
* src/ws.rs — an oversize WS frame was `continue`-skipped, but the PQ AEAD
ratchet's nonce embeds the receive counter and only advances on a successful
decrypt, so a skipped frame permanently desynced (bricked) the connection.
Now closes cleanly instead (axum's max_frame_size already caps upstream).
* src/mcp/tools.rs — background jobs spawn under the tenant job-manager but
list/kill read the process-global one, so tenant jobs were invisible and
unkillable. list/kill now use the tenant job-manager too.
* src/mcp/tools.rs — an out-of-range `port` argument was silently dropped (and
the module fell back to its default); now rejected with a clear error.
* src/ws.rs — a non-string option value was coerced to "" and stored; now
rejected with a clear error.
No silent error-swallowing:
* Swept the framework (non-module) files for the banned swallow patterns
(`Err(_)`, `if let Ok(...)` with no else, bare `.ok();`, `let _ = <result>`,
`.map_err(|_| ...)`) and bound + surfaced every one via `tracing`
(debug for benign fallbacks, warn for lost data/replies) — semantics
unchanged, errors no longer dropped. Legitimate poison-recovery and
deliberate Result->Option conversions were left intact.
Verified: clean build (0 warnings), 40/40 targeted tests green, bad-patterns
audit clean on the changed files.
================================================================================
7. LICENSES (PORTED COMPONENTS)
================================================================================
+45
View File
@@ -1,3 +1,48 @@
## June 2026 (2026-06-13) — Upstream ports + mass-scan fixes + HTTP pooling + per-run auto-save
**389 self-registering native modules.** `cargo build --bin rustsploit`: clean (0 errors, 0 warnings). New-port + core test suites green (`recog tls_fingerprint jarm wordlist cyclic ja3` → 40 passed, 0 failed).
This release ports four well-known upstream projects into Rustsploit, fixes a cluster of mass-scan / full-internet-sweep usability bugs, lands a framework-wide HTTP performance improvement, and adds per-run output auto-save to loot. All ported components are permissively licensed (BSD-2 / BSD-3 / Apache-2.0 / MIT).
### 1. New capabilities (upstream ports)
- **MCP server migrated to the official `rmcp` SDK (Apache-2.0).** `src/mcp/server.rs` is rewritten as a thin adapter implementing rmcp's `ServerHandler` trait; the SDK now owns protocol framing, transport, and spec compliance. The tool/resource logic (`tools.rs`, `resources.rs`, `types.rs`) is preserved unchanged — **all 29 tools and 7 resources** retained, plus the per-call execution timeout (`RUSTSPLOIT_MCP_TIMEOUT_SECS`, default 300s) and the stdout-isolation guard (protocol channel dup'd off fd 1, fd 1 redirected to `/dev/null`). Validated end-to-end over stdio: `initialize` returns `serverInfo = rustsploit-mcp 0.5.0`, negotiates `protocolVersion` to the client's `2024-11-05`, and `tools/list` advertises all 29 tools. `Cargo.toml`: `rmcp = { version = "1.7", features = ["server", "transport-io", "transport-async-rw"] }`.
- **Recog fingerprint engine (Rapid7, BSD-2-Clause).** New `src/utils/recog.rs` — an XML fingerprint-DB loader + matcher (quick-xml; regexes pre-compiled behind `once_cell::Lazy`) that resolves a banner to structured fields (`service.product` / `.version` / `.vendor`, `os.product`, `service.cpe23`, …). Matcher semantics follow real Recog: a `<param>` with a non-empty `value` is a literal/interpolated assignment; a `<param>` with no value reads regex capture group `pos`. Capture params resolve in a first pass so `{field}` templates always bind. Vendored DBs under `src/utils/recog_db/` (ssh / ftp / smtp / http / mysql banners), embedded via `include_str!`. Wired into `scanners/service_scanner`: each banner read (FTP, SSH, SMTP, MySQL, HTTPS `Server:` header) is enriched with a structured product/version + CPE; findings still flow through the existing `FindingKind::Banner` path. 19 unit tests against real example banners. The vendored DBs are a curated, internally-consistent subset — the full Rapid7 DBs are staged but require a per-DB input-normalisation layer before adoption (they anchor on the version comment, not the raw banner line, and use different field names/values).
- **JARM + JA3 / JA3S TLS fingerprinting (Salesforce, BSD-3-Clause).** New `src/utils/tls_fingerprint.rs` — JARM: 10 hand-crafted ClientHello probes (TLS 1.2/1.3, cipher orderings, GREASE on/off, ALPN/extension permutations) sent over a raw tokio `TcpStream`; each ServerHello is bounds-checked and assembled into the canonical 62-char JARM hash. JA3 / JA3S string builders + MD5 (GREASE stripped per spec). Robust failure handling: a down host / TLS alert / truncated response degrades to the all-zero JARM hash; the parser returns `None` on garbage and never panics. New module `src/modules/scanners/jarm_scan.rs` (default port 443) reports JARM hash, JA3S, and client JA3 as findings. `ssl_scanner` intentionally unchanged (rustls hides the raw ServerHello bytes JA3S needs; JA3S is exposed via `tls_fingerprint` for callers holding raw bytes). 14 unit tests.
- **SecLists wordlist catalog (MIT).** Seeded the previously-empty, checksum-pinned catalog in `src/utils/wordlist.rs` with 6 curated SecLists entries — `passwords-top-1k`, `passwords-top-10k`, `usernames-short`, `web-common`, `web-raft-small-dirs`, `subdomains-top5k` — each SHA-256 pinned over the exact upstream bytes. `resolve(name)` downloads + SHA-256-verifies on first use into `~/.rustsploit/wordlists/`, rejecting any checksum mismatch. 2 no-network tests assert every catalog entry is well-formed and uniquely named.
### 2. Bug fixes (mass-scan / full-internet sweep, `src/scheduler.rs`)
- **Full-internet sweep host-cap consistency.** A literal `0.0.0.0/0` typed into `use <mod>; run` previously fell back to the scheduler default of 10,000 hosts (only `setg target 0.0.0.0` auto-raised the cap). Fixed at the single `Target::Random` dispatch chokepoint: when `max_random_hosts` is not explicitly set, a full sweep means every reachable public host on both entry paths. An explicit `setg max_random_hosts <n>` is still honored; the advisory + interactive confirmation gate is unchanged.
- **Confirm BEFORE harvest, and silence the placeholder.** The pre-batch prompt phase runs the module once against placeholder host `0.0.0.1` to harvest answers for the whole batch. (a) Order: the full-sweep advisory + confirmation now runs *first* on both the random and sequential paths; declining aborts with nothing touched. (b) Noise: the harvest run is wrapped in a throwaway `OUTPUT_BUFFER` scope so its `mprintln!`/`meprintln!` output is suppressed while interactive prompts stay visible.
- **`ModuleCtx.prompt_only` (new).** A per-module "prompt-only" mode set on the harvest dry-run ctx. A module that honours the flag answers its `cfg_prompt_*` calls and returns immediately — no network, no file writes against the placeholder. `service_scanner` is the reference implementation; the output-capture guard remains as belt-and-suspenders for modules that don't yet check the flag.
- **`service_scanner` mass-scan output spam + file-save race.** In a fan-out the per-host results table, the per-host file save, and the redundant "No services / Results saved / Scan complete" status lines are now gated on `!is_batch_mode()`. Only hosts with services print; per-host saves are skipped in batch mode (findings flow through the module outcome instead, avoiding the concurrent-overwrite race). Interactive single-target runs are unchanged.
- **`show options` completeness.** Added the four missing global keys to the shell's `show options` table: `scan_order`, `exclusions`, `target_rps`, `module_rps`.
### 3. Performance
- **HTTP client connection-pool reuse (framework-wide).** `src/utils/network.rs::build_http_client(timeout)` now caches the permissive client keyed by `(timeout, accept_invalid_certs)` and returns cheap `Arc` clones — reqwest clients are `Arc` internally and share their connection pool across clones, so warm connections and TLS setup are reused across runs instead of rebuilt per call (once per host in an HTTP mass scan). `accept_invalid_certs` is part of the key so toggling `setg strict_tls` is still honored. The client is built outside the lock; a poisoned lock is recovered rather than panicking. The cached client sets `pool_idle_timeout = 30s` so a full-internet sweep reaps idle keep-alives. Custom-opts clients (cookies / headers / redirects via `build_http_client_with`) are unchanged.
### 4. New: per-run output auto-save
- Every interactive console / CLI module run now auto-appends all of its output (stdout + stderr) to a per-run file `~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt`. Files are opened in append mode so multi-host mass-scan output accumulates into one run file instead of racing to overwrite. New module `src/results_sink.rs` — a global append sink mirroring the spool's write pattern (so spawned per-host task output is captured), hooked into the console branches of `mprintln!`/`meprintln!` routing in `src/output.rs` and begun/ended per run in `commands::run_module`. Scoped to console/CLI (sequential) runs; API / MCP runs return their output to the caller via `OUTPUT_BUFFER` and are not duplicated to disk.
### 5. Docs
- README and `docs/BAD_PATTERNS.md` prose corrected to the exploitation-only model (no `check()` / `CheckResult` verification phase) and compile-time `inventory` self-registration via `register_native_module!` + a `pub mod` line (no `build.rs` module indexer). The BAD_PATTERNS regex matrix (driven by hardcoded bash arrays in the audit script) is untouched.
### Licenses (ported components)
| Component | License |
|---|---|
| Recog (Rapid7) | BSD-2-Clause |
| JARM / JA3 / JA3S (Salesforce) | BSD-3-Clause |
| rmcp (MCP Rust SDK) | Apache-2.0 |
| SecLists | MIT |
| ZMap address iterator (previously ported) | Apache-2.0 |
---
## May 2026 — v0.5.0 Module-System Rewrite
**Module totals: 363 (45 scanners, 284 exploits, 30 creds, 3 osint, 1 plugin)**
+79
View File
@@ -4,6 +4,85 @@ A high-level summary of significant changes. For the full detailed log, see [`ch
---
## 2026-06-13 — Upstream ports (rmcp / Recog / JARM / SecLists), mass-scan fixes, HTTP pooling, per-run auto-save
**389 self-registering native modules.** Build clean (0 errors, 0 warnings).
### New capabilities (upstream ports)
- **MCP server now runs on the official `rmcp` SDK (Apache-2.0, v1.7).** The hand-rolled
JSON-RPC-over-stdio server (`src/mcp/server.rs`) is replaced by a thin adapter implementing
rmcp's `ServerHandler` trait; protocol framing, transport, and spec compliance are owned by the
SDK. The existing tool/resource logic (`tools.rs`, `resources.rs`, `types.rs`) is unchanged — all
**29 tools and 7 resources** are retained, along with the per-call timeout
(`RUSTSPLOIT_MCP_TIMEOUT_SECS`, default 300s) and the fd-1 stdout-isolation guard. See
[`MCP-Integration.md`](MCP-Integration.md).
- **Recog fingerprint engine** (Rapid7, BSD-2-Clause) — new `src/utils/recog.rs`: an XML
fingerprint-database loader + matcher (quick-xml, regexes pre-compiled behind `once_cell::Lazy`)
that resolves a banner to structured fields (`service.product` / `.version` / `.vendor`,
`os.product`, `service.cpe23`, …). Vendored DBs under `src/utils/recog_db/` (ssh / ftp / smtp /
http / mysql banners) are embedded via `include_str!`. Wired into
`scanners/service_scanner` so each banner read (FTP, SSH, SMTP, MySQL, HTTPS `Server:` header) is
enriched with a structured product/version + CPE. The vendored DBs are a curated, internally
consistent subset; the full Rapid7 DBs are staged for a future input-normalisation pass.
- **JARM + JA3 / JA3S TLS fingerprinting** (Salesforce, BSD-3-Clause) — new
`src/utils/tls_fingerprint.rs`: 10 hand-crafted JARM ClientHello probes over a raw tokio
`TcpStream` assembled into the canonical 62-char JARM hash, plus JA3/JA3S string builders + MD5
(GREASE stripped per spec). Parsing is fully bounds-checked and degrades to the all-zero JARM hash
on a down host / TLS alert / truncated response. New module **`scanners/jarm_scan`** (default port
443) reports the JARM hash, JA3S, and client JA3 as findings. `ssl_scanner` is intentionally
unchanged (rustls hides the raw ServerHello bytes JA3S needs).
- **SecLists wordlist catalog** (MIT) — the previously-empty, checksum-pinned catalog in
`src/utils/wordlist.rs` is seeded with 6 curated SecLists entries (`passwords-top-1k`,
`passwords-top-10k`, `usernames-short`, `web-common`, `web-raft-small-dirs`, `subdomains-top5k`).
Each SHA-256 is pinned over the exact upstream bytes; `wordlist::resolve(name)` downloads +
verifies on first use into `~/.rustsploit/wordlists/`, rejecting any checksum mismatch.
### Mass-scan / full-internet-sweep fixes (`src/scheduler.rs`)
- **Full-sweep host-cap consistency** — a literal `0.0.0.0/0` typed into `use <mod>; run` no longer
falls back to the 10,000-host scheduler default. When `max_random_hosts` is not explicitly set, a
full sweep means every reachable public host on both entry paths; an explicit
`setg max_random_hosts <n>` is still honored.
- **Confirm BEFORE harvest** — the full-sweep advisory + interactive confirmation now runs *first*
on both the random and sequential paths; declining aborts with nothing touched. The pre-batch
prompt-harvest run (against placeholder host `0.0.0.1`) is wrapped in a throwaway `OUTPUT_BUFFER`
scope so its module output no longer leaks.
- **`ModuleCtx.prompt_only`** — new per-module "prompt-only" mode. The scheduler sets it on the
harvest dry-run; a module that honours it answers its prompts and returns immediately (no network,
no file writes against the placeholder). `service_scanner` is the reference implementation.
- **`service_scanner` batch-mode output gating** — in a mass-scan fan-out the per-host results
table, file save, and "No services / Results saved / Scan complete" status lines are now gated on
`!is_batch_mode()`, fixing cross-task output spam and the concurrent same-file overwrite race.
Interactive single-target runs are unchanged.
- **`show options`** — the shell table now includes the four previously-missing global keys:
`scan_order`, `exclusions`, `target_rps`, `module_rps`.
### Performance
- **HTTP client connection-pool reuse** — `utils::network::build_http_client(timeout)` now caches
the permissive client keyed by `(timeout, accept_invalid_certs)` and returns cheap `Arc` clones, so
warm connections and TLS setup are reused across runs instead of rebuilt per call. The cached
client sets a bounded `pool_idle_timeout` (30s) so a full-internet sweep reaps idle keep-alives.
Toggling `setg strict_tls` is still honored (it is part of the cache key). Custom-opts clients
(cookies / headers / redirects) are unchanged.
### New: per-run output auto-save
- Every interactive console / CLI module run now auto-appends all of its output (stdout + stderr) to
a per-run file `~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt` (append mode, via
new `src/results_sink.rs`). Append mode means multi-host mass-scan output accumulates into one run
file instead of racing to overwrite. Scoped to console/CLI (sequential) runs; API / MCP runs
return their output to the caller via `OUTPUT_BUFFER` and are not duplicated to disk.
### Docs
- README and `docs/BAD_PATTERNS.md` prose corrected to the exploitation-only model (no
`check()` / `CheckResult` verification phase) and compile-time `inventory` self-registration (no
`build.rs` module indexer). The BAD_PATTERNS regex matrix is untouched.
---
## 2026-06-04 — Sequential mass scan + interactive-module timeout fix
- **Sequential mass scan** — new `Target::Sequential(start)` walks the public IPv4 space
+4 -7
View File
@@ -32,10 +32,12 @@ Use subfolders for vendor families (e.g., `exploits/cisco/`, `exploits/cameras/`
### Recommended: Add Module Metadata
Consider adding `info()` and/or `check()` functions to your module:
Add an `info()` function describing your module. Rustsploit is **exploitation-only**
— do not add a `check()` / `CheckResult` verification phase; modules run an exploit
and report `Finding`s (see _Auto-Store Findings_ below).
```rust
use crate::module_info::{ModuleInfo, ModuleRank, CheckResult};
use crate::module_info::{ModuleInfo, ModuleRank};
pub fn info() -> ModuleInfo {
ModuleInfo {
@@ -48,11 +50,6 @@ pub fn info() -> ModuleInfo {
default_port: Some(8080),
}
}
pub async fn check(ctx: &ModuleCtx) -> CheckResult {
// Non-destructive verification only
CheckResult::Unknown("Not implemented".to_string())
}
```
### Auto-Store Findings
+8 -2
View File
@@ -29,10 +29,16 @@ if response.status() == 200 && body.contains("expected_indicator") {
## Error Handling — no `.unwrap()`, no swallowing
> **Note:** Rustsploit is exploitation-only — the `check()` / `CheckResult`
> verification phase was removed. The `check(...)` snippets below are kept only
> to illustrate the error-handling discipline; apply the same patterns in your
> `run(&ModuleCtx) -> Result<ModuleOutcome>` body and emit `Finding`s instead of
> returning a `CheckResult`.
Modules must surface transport and decode failures explicitly. The framework
treats a panicking module as a hard build/runtime failure, and silently
swallowing a failed `send()` or `text()` causes both `check()` and `run()`
to lie about what happened on the wire.
swallowing a failed `send()` or `text()` causes `run()` to lie about what
happened on the wire.
**Forbidden patterns:**
+1 -1
View File
@@ -25,7 +25,7 @@ Welcome to the Rustsploit documentation hub. Use the links below to navigate to
| [Utilities & Helpers](Utilities-Helpers.md) | Network wrappers, creds_helper, exploit_helper, source port binding, cfg_prompt_* |
| [Bad Patterns](BAD_PATTERNS.md) | Banned code patterns — 95+ regex checks for panics, error swallowing, network bypasses |
| [Testing & QA](Testing-QA.md) | Build checks (0 errors, 0 warnings), smoke tests, wordlist validation |
| [Changelog](Changelog.md) | Release notes and version history (current: v0.5.0) |
| [Changelog](Changelog.md) | Release notes and version history (latest: 2026-06-13 — rmcp / Recog / JARM / SecLists ports, mass-scan fixes, per-run auto-save) |
| [Contributing](Contributing.md) | Fork guide, PR checklist, code style |
| [Credits](Credits.md) | Authors, acknowledgements, legal notice |
| [Future Features](Future-Features.md) | Roadmap and completed features (plugins, metadata, global options, etc.) |
+12 -1
View File
@@ -29,7 +29,7 @@ All commands are **case-insensitive** and support aliases:
| `check` | `ch` | Non-destructive vulnerability check |
| `setg <key> <val>` | `sg` | Set a global option (persists across modules) |
| `unsetg <key>` | `ug` | Remove a global option |
| `show options` | `so` | Display all global options |
| `show options` | `so` | Display all global options (now includes `scan_order`, `exclusions`, `target_rps`, `module_rps`) |
| `creds` | | List stored credentials |
| `creds add` | | Add a credential interactively |
| `creds search <q>` | | Search credentials by host/service/user |
@@ -200,6 +200,7 @@ The shell accepts Metasploit-style option names and maps them to Rustsploit keys
| `prescan` | `set prescan auto` | Pre-scan tool for CIDR (auto/masscan/zmap/none) |
| `prescan_rate` | `set prescan_rate 1000` | Pre-scan packets per second |
| `scan_order` | `set scan_order sequential` | Mass-scan order for `0.0.0.0/0`/`random`: `random` (default) or `sequential` |
| `exclusions` | `set exclusions 10.0.0.0/8,@/path/file` | CIDRs/ranges to skip in mass scans (`""`=defaults, `none`/`internal`=no filtering) |
| `target_mac` | `setg target_mac AA:BB:CC:DD:EE:FF` | wpair: target a single Fast Pair device |
| `adapter` | `setg adapter 1` | wpair: BLE adapter index |
| `scan_secs` | `setg scan_secs 20` | wpair: BLE scan window (3300 s) |
@@ -259,6 +260,16 @@ Rustsploit tracks engagement data across sessions:
Export all data with `export json report.json`, `export csv report.csv`, or `export summary report.txt`.
### Per-run output auto-save
Every shell / CLI module run also auto-appends its full console output (stdout + stderr) to a per-run file under the loot directory:
```text
~/.rustsploit/loot/<module> <YYYY-MM-DD_HH-MM-SS> results.txt
```
Files are opened in **append** mode, so a multi-host mass scan accumulates into one run file instead of racing to overwrite it. This is automatic and independent of `spool` (which logs the whole session) and of any module's own `save_results` option. API / MCP runs are not duplicated to disk — their output is returned to the caller.
---
## Background Jobs
+17 -14
View File
@@ -2,6 +2,8 @@
Rustsploit includes a built-in MCP (Model Context Protocol) server that enables integration with Claude Desktop and other MCP-compatible clients. The server communicates via JSON-RPC 2.0 over stdio (stdin/stdout), with no network listener.
> **As of the 2026-06-13 release the server runs on the official MCP Rust SDK, [`rmcp`](https://crates.io/crates/rmcp) (v1.7, Apache-2.0).** `src/mcp/server.rs` is now a thin adapter implementing rmcp's `ServerHandler` trait — protocol framing, transport, and spec compliance are owned by the SDK (with correct resources/prompts/progress support). The tool and resource logic (`tools.rs`, `resources.rs`, `types.rs`) is unchanged, so the **29 tools and 7 resources** below, the per-call timeout, and the stdout-isolation guard all carry over.
---
## Starting the MCP Server
@@ -34,7 +36,7 @@ The server reads one JSON-RPC 2.0 request per line from stdin and writes one res
---
## Tools (42)
## Tools (29)
### Module Tools
@@ -43,7 +45,7 @@ The server reads one JSON-RPC 2.0 request per line from stdin and writes one res
| `list_modules` | List all available modules, optionally filtered by category | -- |
| `search_modules` | Search modules by keyword (case-insensitive substring match) | `query` |
| `module_info` | Get metadata for a specific module (name, description, authors, references, rank) | `module_path` |
| `check_module` | Run a non-destructive vulnerability check against a target | `module_path`, `target` |
| `check_module` | Run a module's `check` entry point against a target | `module_path`, `target` |
### Target Tools
@@ -169,10 +171,12 @@ Replace `/path/to/rustsploit` with the absolute path to your compiled binary (e.
- **Stdio transport only** -- no network listener, no authentication needed (single-user process)
- **Target injection prevention** -- `run_module` strips any `target` key from the `prompts` object to prevent SSRF via prompt injection
- **Module validation** -- module paths are verified against the build-time discovered module list before execution
- **Module validation** -- module paths are verified against the compile-time `inventory` module registry before execution
- **Credential redaction** -- the `rustsploit:///credentials` resource shows only the first 3 characters of each secret
- **No file system writes** -- MCP tools return data inline; no direct file read/write operations are exposed
- **No file system writes** -- MCP tools return data inline; no direct file read/write operations are exposed. (Unlike the console / CLI, MCP runs are not duplicated to the per-run loot auto-save file — their output is returned inline to the caller.)
- **Concurrency bounded** -- module execution is limited by the framework's semaphore (CPU count, minimum 4 concurrent)
- **Per-call timeout** -- each tool call is bounded by `RUSTSPLOIT_MCP_TIMEOUT_SECS` (default 300s)
- **Stdout isolation** -- the JSON-RPC protocol channel is dup'd off fd 1 and fd 1 is redirected to `/dev/null`, so a stray `println!` from a module can never corrupt the JSON-RPC stream
---
@@ -181,21 +185,20 @@ Replace `/path/to/rustsploit` with the absolute path to your compiled binary (e.
```
src/mcp/
mod.rs -- Module re-exports
types.rs -- JSON-RPC 2.0 types, MCP capability structs, Tool/Resource/ToolResult types
server.rs -- Stdio event loop, request routing, response serialization
tools.rs -- 42 tool definitions and dispatch handlers
types.rs -- MCP capability structs, Tool/Resource/ToolResult types
server.rs -- rmcp `ServerHandler` adapter (SDK owns transport + JSON-RPC framing)
tools.rs -- 29 tool definitions and dispatch handlers
resources.rs -- 7 resource definitions and read handlers
client.rs -- MCP client implementation (for connecting to external MCP servers)
```
### Request Flow
1. `server.rs` reads a JSON line from stdin
2. Parses it as a `JsonRpcRequest`
3. Routes by method name: `initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`
4. Handler extracts typed parameters from `params`
5. Calls framework APIs (same functions used by the REST API and interactive shell)
6. Returns a `JsonRpcResponse` serialized as a single JSON line on stdout
1. The `rmcp` SDK reads and frames each JSON-RPC message off the stdio transport
2. The SDK dispatches to the `ServerHandler` adapter in `server.rs` by method (`initialize`, `tools/list`, `tools/call`, `resources/list`, `resources/read`)
3. The handler extracts typed parameters and dispatches to `tools.rs` / `resources.rs`
4. Those call framework APIs (the same functions used by the REST API and interactive shell)
5. The SDK serializes the result back onto the stdio transport
---
@@ -203,7 +206,7 @@ src/mcp/
```
-> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"rustsploit-mcp","version":"0.4.8"}}}
<- {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"rustsploit-mcp","version":"0.5.0"}}}
-> {"jsonrpc":"2.0","method":"initialized","params":{}}
+2
View File
@@ -1,6 +1,8 @@
# Module Catalog
> Auto-generated from the inventory registry by `rustsploit --gen-module-catalog`. Do not edit by hand.
>
> **Stale snapshot.** This file last reflects 363 modules; the current build has **389** (the 2026-06-13 release added `scanners/jarm_scan` and others). Re-run `rustsploit --gen-module-catalog > docs/Module-Catalog.md` to regenerate the live list.
**Total registered modules: 363**
+30 -8
View File
@@ -56,8 +56,8 @@ rustsploit/
│ ├── jobs.rs # Background job management
│ ├── mcp/
│ │ ├── mod.rs # MCP server entry point (--mcp flag)
│ │ ├── server.rs # JSON-RPC stdio transport with binary-safe reads
│ │ └── tools.rs # 38 MCP tool implementations
│ │ ├── 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
@@ -207,13 +207,22 @@ The `info` shell command and `GET /api/module/{category}/{name}` endpoint displa
**Rank values:** `Excellent` (reliable, no crash risk), `Great`, `Good` (default), `Normal`, `Low`, `Manual`.
### Vulnerability Check (`check`)
### 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 {
// Non-destructive verification — do NOT exploit
// (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()),
@@ -222,8 +231,6 @@ pub async fn check(target: &str) -> CheckResult {
}
```
The `check` shell command and `POST /api/check` endpoint run this without exploitation.
### Auto-Store Credentials and Loot
Modules can auto-store discovered data:
@@ -674,7 +681,20 @@ if crate::utils::is_batch_mode() {
## Wordlists & Resources
Store under `lists/` and document them in `lists/readme.md`. Reference paths relative to the working directory.
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/<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.
---
@@ -742,7 +762,9 @@ Active workstreams (snapshot — see `docs/Legacy.md` for the running ledger):
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 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
-1
View File
@@ -149,7 +149,6 @@ curl http://localhost:8080/health
| API change | `cargo check` clean, endpoint documented in [API Server](API-Server.md) |
| Utils change | All prompt helpers still compile, no dead code warnings |
| Module with `info()` | `info` command displays metadata |
| Module with `check()` | `check` command runs verification |
| Source port | Connections use `tcp_connect_str`/`udp_bind`, not raw socket calls |
| Batch mode | Interactive/REPL modules bail with `is_batch_mode()` guard |
| Global options change | JSON file updated atomically, `cfg_prompt_*` respects priority chain |
+17 -7
View File
@@ -5,7 +5,10 @@ Rustsploit provides several utility modules that every module developer should k
| Module | Import Path | Purpose |
|--------|-------------|---------|
| **Core Utils** | `crate::utils` | Target normalization, file loading, config-aware prompts, input validation |
| **Network Utils** | `crate::utils::network` | HTTP client builders, TCP/UDP connect helpers, honeypot check |
| **Network Utils** | `crate::utils::network` | HTTP client builders (cached, pooled), TCP/UDP connect helpers, honeypot check |
| **Recog** | `crate::utils::recog` | Rapid7-Recog banner matcher — resolves a banner to structured `service.product`/`.version`/`.cpe23`, `os.product` (used by `service_scanner`) |
| **TLS Fingerprint** | `crate::utils::tls_fingerprint` | JARM (62-char hash), JA3, JA3S over a raw `TcpStream` (used by `jarm_scan`) |
| **Wordlist** | `crate::utils::wordlist` | Checksum-pinned `resolve(name)` (SecLists catalog) + streaming `BatchedReader` |
| **Privilege Utils** | `crate::utils::privilege` | Root privilege check for raw-socket modules |
| **Creds Helper** | `crate::utils::creds_helper` | Single-target credential brute-force harness (prompt, engine, loot, findings) |
| **Exploit Helper** | `crate::utils::exploit_helper` | HTTP probe helpers for single-target CVE exploit modules |
@@ -709,7 +712,9 @@ use crate::utils::network::{
### `build_http_client(timeout) → Result<Client>`
Creates a standard `reqwest::Client` with sensible defaults (danger-accept invalid certs, no redirect limit). Use this instead of hand-rolling `reqwest::Client::builder()`.
Returns the shared permissive `reqwest::Client` (danger-accept invalid certs, no redirect limit). Use this instead of hand-rolling `reqwest::Client::builder()`.
As of the 2026-06-13 release this client is **cached and reused** — it is keyed by `(timeout, accept_invalid_certs)` and returned as a cheap `Arc` clone, so warm connections and TLS setup are shared across runs (reqwest clients are `Arc` internally and share their connection pool across clones) instead of rebuilt per call. `accept_invalid_certs` is part of the key, so toggling `setg strict_tls` is honored rather than pinned to the first build. The cached client also sets a bounded `pool_idle_timeout` (30s) so a long mass scan reaps idle keep-alives. Custom-opts clients from `build_http_client_with` (cookies / headers / redirects) are *not* cached.
```rust
use crate::utils::network::build_http_client;
@@ -868,13 +873,18 @@ Failure modes: unknown name (suggests close matches via Levenshtein), HTTP error
Returns the names of every wordlist this build knows about. Useful for `--list-wordlists`-style introspection.
The catalogue is **intentionally empty by default**. Entries get added by maintainers after fetching + hashing each upstream artefact:
As of the 2026-06-13 release the catalogue (`KNOWN_LISTS`) is seeded with 6 curated SecLists entries (MIT):
```bash
curl -L <url> | sha256sum
```
| Name | SecLists path |
|------|---------------|
| `passwords-top-1k` | `Passwords/Common-Credentials/Pwdb_top-1000.txt` |
| `passwords-top-10k` | `Passwords/Common-Credentials/Pwdb_top-10000.txt` |
| `usernames-short` | `Usernames/top-usernames-shortlist.txt` |
| `web-common` | `Discovery/Web-Content/common.txt` |
| `web-raft-small-dirs` | `Discovery/Web-Content/raft-small-directories.txt` |
| `subdomains-top5k` | `Discovery/DNS/subdomains-top1million-5000.txt` |
There is no TODO placeholder — placeholder hashes that look real but aren't are an integrity hole, so the slot stays empty until verified. To request a wordlist be added, open a PR adding a `WordlistSpec { name, url, sha256, local_name }` tuple to `KNOWN_LISTS`.
Each entry's SHA-256 is pinned over the exact upstream bytes `resolve()` hashes after download — placeholder hashes that look real but aren't are an integrity hole, so a slot stays empty until verified. To add a wordlist, fetch + hash the upstream artefact (`curl -L <url> | sha256sum`) and add a `WordlistSpec { name, url, sha256, local_name }` tuple to `KNOWN_LISTS`.
### `BatchedReader` — streaming reader
+8 -5
View File
@@ -369,13 +369,16 @@ pub fn list_checkpoints() -> Result<Vec<Checkpoint>> {
// Per-tenant namespaces.
let tenants_dir = dir.join("tenants");
if tenants_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
for entry in entries.flatten() {
let tpath = entry.path();
if tpath.is_dir() {
collect_checkpoints_in(&tpath, &mut out);
match std::fs::read_dir(&tenants_dir) {
Ok(entries) => {
for entry in entries.flatten() {
let tpath = entry.path();
if tpath.is_dir() {
collect_checkpoints_in(&tpath, &mut out);
}
}
}
Err(e) => tracing::debug!("read tenants checkpoint dir {} failed: {e}", tenants_dir.display()),
}
}
out.sort_by(|a, b| a.started.cmp(&b.started));
+12 -3
View File
@@ -94,9 +94,17 @@ fn module_timeout() -> Option<std::time::Duration> {
Ok(0) => None,
Ok(secs) => Some(std::time::Duration::from_secs(secs)),
// Unparseable override falls back to the default rather than panicking.
Err(_) => Some(std::time::Duration::from_secs(300)),
Err(e) => {
tracing::debug!("RUSTSPLOIT_MCP_TIMEOUT_SECS is not a valid u64, using 300s default: {e}");
Some(std::time::Duration::from_secs(300))
}
},
Err(_) => Some(std::time::Duration::from_secs(300)),
// The variable is normally unset (use the default); a NotUnicode read
// error is the only surprising case, so trace it rather than drop it.
Err(e) => {
tracing::trace!("RUSTSPLOIT_MCP_TIMEOUT_SECS unreadable ({e}); using 300s default");
Some(std::time::Duration::from_secs(300))
}
}
}
@@ -192,7 +200,8 @@ impl ServerHandler for RustsploitHandler {
Some(dur) => {
match tokio::time::timeout(dur, super::tools::call_tool(&name, arguments)).await {
Ok(r) => r,
Err(_) => {
Err(elapsed) => {
tracing::debug!("MCP tool '{}' timed out: {elapsed}", name);
eprintln!(
"[MCP] tool '{}' exceeded {}s timeout — aborting call",
name,
+17 -5
View File
@@ -594,9 +594,17 @@ async fn handle_run_module(args: &Value) -> ToolResult {
}
let mut prompts = prompts_param(args);
// Inject port into prompts if provided as a top-level parameter
if let Some(port) = u16_param(args, "port") {
prompts.entry("port".into()).or_insert_with(|| port.to_string());
// Port is optional: absent or null means "use the module default". If it IS
// present it must be a valid 1-65535 integer — reject an out-of-range value
// explicitly rather than silently dropping it (which would fall back to the
// default and hide the operator's typo).
if args.get("port").is_some_and(|v| !v.is_null()) {
match u16_param(args, "port") {
Some(port) => {
prompts.entry("port".into()).or_insert_with(|| port.to_string());
}
None => return ToolResult::error("Invalid 'port': must be an integer 1-65535".into()),
}
}
// Strip "target" from prompts to prevent SSRF bypass via prompt injection
prompts.remove("target");
@@ -907,7 +915,10 @@ async fn handle_unset_option(args: &Value) -> ToolResult {
// ── Jobs ──────────────────────────────────────────────────────────────────
fn handle_list_jobs() -> ToolResult {
let jobs = crate::jobs::JOB_MANAGER.list();
// Use the tenant-scoped job manager — the same one `run_module` spawns into
// (line ~639) — so a job started under a non-default tenant is visible here
// instead of vanishing into the process-global manager.
let jobs = crate::tenant::resolve().job_manager().list();
let entries: Vec<Value> = jobs
.into_iter()
.map(|(id, module, target, started, status)| {
@@ -928,7 +939,8 @@ fn handle_kill_job(args: &Value) -> ToolResult {
Some(v) => v,
None => return ToolResult::error("Missing required parameter: id (integer)".into()),
};
if crate::jobs::JOB_MANAGER.kill(id) {
// Tenant-scoped (matches spawn + list) so tenant jobs are killable.
if crate::tenant::resolve().job_manager().kill(id) {
ToolResult::text(format!("Job {} killed", id))
} else {
ToolResult::error(format!("Job {} not found", id))
+2 -2
View File
@@ -220,11 +220,11 @@ fn build_x224_cr(cookie: &str, protocols: u32) -> Result<Vec<u8>> {
let mut pdu = Vec::with_capacity(tpkt_len);
// TPKT header
let tpkt_u16 = u16::try_from(tpkt_len)
.map_err(|_| anyhow!("X.224 connection request too large ({} bytes); max cookie length ~500 bytes", tpkt_len))?;
.map_err(|e| anyhow!("X.224 connection request too large ({} bytes); max cookie length ~500 bytes: {e}", tpkt_len))?;
pdu.extend_from_slice(&[TPKT_VERSION, 0, (tpkt_u16 >> 8) as u8, tpkt_u16 as u8]);
// X.224 CR
let x224_len_u8 = u8::try_from(x224_payload_len)
.map_err(|_| anyhow!("X.224 payload length too large ({} bytes); max cookie length ~500 bytes", x224_payload_len))?;
.map_err(|e| anyhow!("X.224 payload length too large ({} bytes); max cookie length ~500 bytes: {e}", x224_payload_len))?;
pdu.push(x224_len_u8); // length indicator
pdu.push(X224_TYPE_CR);
pdu.extend_from_slice(&[0, 0, 0, 0, 0]); // dst-ref(2) + src-ref(2) + class(1)
+6 -1
View File
@@ -758,7 +758,12 @@ pub fn process_handshake(
.clone()
.into_boxed_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("Classic McEliece public key failed fixed-size conversion"))?;
.map_err(|e: Box<[u8]>| {
anyhow::anyhow!(
"Classic McEliece public key failed fixed-size conversion: got {} bytes",
e.len()
)
})?;
let pk = McePublicKey::from(pk_boxed);
// The crate is pinned to rand 0.8; supply its OsRng (CSPRNG, OS-seeded).
let (mce_ct, mce_ss) = encapsulate_boxed(&pk, &mut rand08::rngs::OsRng);
+3 -2
View File
@@ -443,8 +443,9 @@ pub async fn pq_middleware(
// Restore the original semantic HTTP method so the inner router matches
// GET/PUT/DELETE handlers — the wire request was always POST so without
// this restore, only POST routes would dispatch.
if let Ok(restored) = semantic_method.parse::<axum::http::Method>() {
parts.method = restored;
match semantic_method.parse::<axum::http::Method>() {
Ok(restored) => parts.method = restored,
Err(e) => tracing::debug!("X-PQ-Method {:?} is not a valid HTTP method, keeping wire method: {e}", semantic_method),
}
let mut new_req = Request::from_parts(parts, Body::from(inner_body));
+4 -1
View File
@@ -271,7 +271,10 @@ async fn run_capture_lines(
match tokio::time::timeout(wall_timeout, read_loop).await {
Ok(res) => res?,
Err(_) => timed_out = true,
Err(elapsed) => {
tracing::trace!("prescan read loop hit wall_timeout: {elapsed}");
timed_out = true;
}
}
// On the timeout *or* output-cap truncation path the child is still
+3 -2
View File
@@ -60,8 +60,9 @@ impl Bucket {
return;
}
// forget() the permit so it's only refilled by the ticker.
if let Ok(p) = self.sem.clone().acquire_owned().await {
p.forget();
match self.sem.clone().acquire_owned().await {
Ok(p) => p.forget(),
Err(e) => tracing::debug!("rate limiter semaphore closed during acquire: {e}"),
}
}
}
+114 -51
View File
@@ -367,6 +367,68 @@ fn current_module_path(module: &dyn Module) -> String {
info_name
}
/// One initial attempt plus a single retry per host. Bounded so a transient
/// blip is absorbed without doubling the wall-clock of a full sweep.
const MAX_HOST_ATTEMPTS: usize = 2;
/// Classify an error as a transient network condition worth one retry. A closed
/// port (`ConnectionRefused`) or a non-IO module error (e.g. "target not
/// affected") is terminal, so a mass scan never wastes a retry on a host that
/// simply does not run the service.
fn is_transient_error(err: &anyhow::Error) -> bool {
for cause in err.chain() {
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
use std::io::ErrorKind;
if matches!(
io.kind(),
ErrorKind::TimedOut
| ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::BrokenPipe
| ErrorKind::UnexpectedEof
) {
return true;
}
}
}
// The per-host deadline wrapper produces an anyhow error (not an io::Error)
// whose message starts with "timed out".
err.to_string().contains("timed out")
}
/// Run a module against one host with bounded retry on transient failures, then
/// return the outcome for the caller to record. A transient error is retried up
/// to `MAX_HOST_ATTEMPTS` with a short backoff; any other error returns
/// immediately. Either way a single host never aborts the surrounding mass-scan
/// loop — the caller records the outcome and continues to the next host.
async fn run_host_with_retry(
module: &Arc<dyn Module>,
ctx: &ModuleCtx,
per_host_timeout: Option<Duration>,
) -> Result<ModuleOutcome> {
let mut attempt = 0usize;
loop {
attempt += 1;
let res = match per_host_timeout {
Some(d) => match tokio::time::timeout(d, module.run(ctx)).await {
Ok(r) => r,
Err(e) => Err(anyhow::anyhow!("timed out: {e}")),
},
None => module.run(ctx).await,
};
match res {
Ok(outcome) => return Ok(outcome),
Err(e) => {
if attempt < MAX_HOST_ATTEMPTS && !ctx.is_cancelled() && is_transient_error(&e) {
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
return Err(e);
}
}
}
}
// ============================================================
// FAN-OUT
// ============================================================
@@ -461,7 +523,9 @@ async fn fanout_cidr(
let FanoutParams { module, target, options, cancel, tenant_id, limits, module_path } = params;
let cidr = match &target {
Target::Cidr(s) => s.clone(),
_ => unreachable!("fanout_cidr called with non-Cidr target"),
// Reachable only via an internal dispatch bug; surface it as a clean
// error rather than panicking the process (and it clears the no-panic gate).
_ => anyhow::bail!("internal error: fanout_cidr called with non-Cidr target"),
};
let network = crate::utils::parse_subnet(&cidr)?;
let host_count = crate::utils::subnet_host_count(&network);
@@ -646,13 +710,12 @@ async fn fanout_cidr(
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = tokio::time::timeout(
Duration::from_secs(limits.timeout_secs),
module_clone.run(&ctx),
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(limits.timeout_secs)),
)
.await
.map_err(|e| anyhow::anyhow!("timed out: {e}"))
.and_then(|r| r);
.await;
stats_clone.record(outcome);
record_checkpoint(&cp_clone, &ip_str).await;
});
@@ -674,7 +737,7 @@ async fn fanout_file(
let FanoutParams { module, target, options, cancel, tenant_id, limits, module_path } = params;
let path = match &target {
Target::File(p) => p.clone(),
_ => unreachable!("fanout_file called with non-File target"),
_ => anyhow::bail!("internal error: fanout_file called with non-File target"),
};
let content = crate::utils::safe_read_to_string_async(
path.to_str().unwrap_or(""),
@@ -772,7 +835,10 @@ async fn fanout_file(
// hostname entries keep the honeypot-only check.
let skip = match host.parse::<std::net::IpAddr>() {
Ok(ip) => !crate::utils::network::mass_scan_precheck(ip, precheck_port, honeypot).await,
Err(_) => honeypot && crate::utils::network::quick_honeypot_check(&host).await,
Err(e) => {
tracing::trace!("'{host}' is not an IP literal ({e}); honeypot-only precheck");
honeypot && crate::utils::network::quick_honeypot_check(&host).await
}
};
if skip {
stats_clone.skipped.fetch_add(1, Ordering::Relaxed);
@@ -787,13 +853,12 @@ async fn fanout_file(
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = tokio::time::timeout(
Duration::from_secs(limits.timeout_secs),
module_clone.run(&ctx),
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(limits.timeout_secs)),
)
.await
.map_err(|e| anyhow::anyhow!("timed out: {e}"))
.and_then(|r| r);
.await;
stats_clone.record(outcome);
record_checkpoint(&cp_clone, &host).await;
});
@@ -819,7 +884,7 @@ async fn fanout_multi(
) -> Result<ModuleOutcome> {
let parts = match target {
Target::Multi(p) => p,
_ => unreachable!("fanout_multi called with non-Multi target"),
_ => anyhow::bail!("internal error: fanout_multi called with non-Multi target"),
};
let total = parts.len();
if let Some(ref tid) = tenant_id {
@@ -1025,27 +1090,25 @@ async fn fanout_random(
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = tokio::time::timeout(
Duration::from_secs(limits.timeout_secs),
module_clone.run(&ctx),
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(limits.timeout_secs)),
)
.await
.map_err(|e| anyhow::anyhow!("timed out: {e}"))
.and_then(|r| r);
.await;
let was_err = stats_clone.record(outcome);
record_checkpoint(&cp_clone, &ip.to_string()).await;
if was_err {
let n = module_err_clone.fetch_add(1, Ordering::Relaxed) + 1;
let ok_so_far = stats_clone.success.load(Ordering::Relaxed);
if n >= 10
&& ok_so_far == 0
&& !stats_clone.abort.swap(true, Ordering::Relaxed)
{
// Warn once if the run looks misconfigured, but keep going:
// transient failures are already retried, and the operator asked
// for error -> retry -> continue rather than an automatic abort.
if n == 10 && ok_so_far == 0 {
crate::meprintln!(
"{}",
"[!] First 10 module dispatches all errored with no successes — aborting random mass scan."
.red()
.bold()
"[!] First 10 dispatches errored with no successes — continuing anyway (check module/target config if this persists; Ctrl+C to stop)."
.yellow()
);
}
}
@@ -1250,23 +1313,24 @@ async fn fanout_sequential(
ctx.batch_mode = true;
ctx.prompt_cache = Some(cache);
ctx.module_path = mp;
let outcome = tokio::time::timeout(Duration::from_secs(timeout_secs), module_clone.run(&ctx))
.await
.map_err(|e| anyhow::anyhow!("timed out: {e}"))
.and_then(|r| r);
let outcome = run_host_with_retry(
&module_clone,
&ctx,
Some(Duration::from_secs(timeout_secs)),
)
.await;
let was_err = stats_clone.record(outcome);
if was_err {
let n = module_err_clone.fetch_add(1, Ordering::Relaxed) + 1;
let ok_so_far = stats_clone.success.load(Ordering::Relaxed);
if n >= 10
&& ok_so_far == 0
&& !stats_clone.abort.swap(true, Ordering::Relaxed)
{
// Warn once if the run looks misconfigured, but keep going:
// transient failures are already retried, and the operator asked
// for error -> retry -> continue rather than an automatic abort.
if n == 10 && ok_so_far == 0 {
crate::meprintln!(
"{}",
"[!] First 10 module dispatches all errored with no successes — aborting sequential mass scan."
.red()
.bold()
"[!] First 10 dispatches errored with no successes — continuing anyway (check module/target config if this persists; Ctrl+C to stop)."
.yellow()
);
}
}
@@ -1369,18 +1433,17 @@ async fn pre_batch_prompt(
);
let result = tokio::time::timeout(Duration::from_secs(300), run_fut).await;
// Regardless of success/failure, the prompts have been answered and
// stored in global options by cfg_prompt_*. Swallow the error.
// The prompts have already been answered and stored by cfg_prompt_*. The
// dry-run's outcome is not actionable (it exists only to harvest prompts),
// so each arm binds and logs it at debug — NOT propagated, because
// propagating an expected dry-run error would abort the whole batch.
match result {
Ok(Ok(_)) => {
tracing::debug!("pre_batch_prompt: module dry-run succeeded (unexpected)");
}
Ok(Err(e)) => {
tracing::debug!("pre_batch_prompt: module dry-run errored (expected): {e:#}");
}
Err(_) => {
tracing::debug!("pre_batch_prompt: module dry-run timed out (expected)");
}
Ok(Ok(outcome)) => tracing::debug!(
"pre_batch_prompt: dry-run completed with {} finding(s) (ignored — placeholder host)",
outcome.findings.len()
),
Ok(Err(e)) => tracing::warn!("pre_batch_prompt: config dry-run errored (prompts may be incomplete): {e:#}"),
Err(elapsed) => tracing::warn!("pre_batch_prompt: config dry-run timed out (prompts may be incomplete): {elapsed}"),
}
crate::mprintln!();
+9
View File
@@ -67,6 +67,15 @@ impl Completer for RsfCompleter {
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
// rustyline's cursor `pos` is a raw byte offset. Clamp it to the line
// length and snap it back to a UTF-8 char boundary before slicing — a
// cursor landing mid-multibyte-codepoint would otherwise panic here and
// crash the entire interactive shell. Byte 0 is always a boundary, so
// this terminates.
let mut pos = pos.min(line.len());
while pos > 0 && !line.is_char_boundary(pos) {
pos -= 1;
}
let line_up_to_cursor = &line[..pos];
// After "use " or "u ", complete module paths
+72 -37
View File
@@ -340,18 +340,21 @@ async fn handle_ws(
};
if frame_bytes.len() > MAX_WS_FRAME_SIZE {
// Drop the oversized frame and keep the connection alive — a
// single bad frame should not disconnect a long-running session.
// The peer can retry with a smaller frame; if they keep sending
// oversize frames, the noise is bounded by `MAX_TOTAL_CONNECTIONS`
// and will surface as repeated warnings in the trace log.
// Close the connection on an oversize frame. The AEAD ratchet's
// nonce embeds the receive counter and only advances on a SUCCESSFUL
// decrypt, so skipping a frame (the old `continue`) permanently
// desynced the channel and bricked every subsequent frame anyway —
// "keep the connection alive" was unachievable. axum's
// `max_frame_size` already rejects oversize at the framing layer, so
// reaching here means a misbehaving peer; close cleanly (matching the
// decrypt-failure path below) instead of silently wedging.
tracing::warn!(
"WS frame too large from {}: {} bytes (cap {}); dropping frame, connection kept alive",
"WS frame too large from {}: {} bytes (cap {}); closing connection",
addr,
frame_bytes.len(),
MAX_WS_FRAME_SIZE
);
continue;
break;
}
let plaintext = {
@@ -369,11 +372,14 @@ async fn handle_ws(
Ok(v) => v,
Err(e) => {
let err_resp = json!({"error": {"code": "PARSE_ERROR", "message": format!("Invalid JSON: {}", e)}});
if let Ok(bytes) = serde_json::to_vec(&err_resp) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&err_resp) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize parse-error response: {e}"),
}
continue;
}
@@ -390,11 +396,14 @@ async fn handle_ws(
Err(e) => {
tracing::debug!("jobId exceeds u32 range: {e}");
let resp = json!({"id": req_id, "error": {"code": "INVALID_JOB_ID", "message": "jobId exceeds u32 range"}});
if let Ok(bytes) = serde_json::to_vec(&resp) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&resp) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize INVALID_JOB_ID response: {e}"),
}
continue;
}
@@ -402,11 +411,14 @@ async fn handle_ws(
let mut jobs = reader_jobs.lock().await;
if jobs.len() >= 100 {
let resp = json!({"id": req_id, "error": {"code": "SUB_LIMIT", "message": "Max 100 job subscriptions per connection"}});
if let Ok(bytes) = serde_json::to_vec(&resp) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&resp) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize SUB_LIMIT response: {e}"),
}
} else {
// Send the ack BEFORE inserting: if the send fails the
@@ -428,11 +440,14 @@ async fn handle_ws(
// Missing/non-numeric jobId: a JSON-RPC client keyed by req_id
// would hang forever without an ack or error, so emit one.
let resp = json!({"id": req_id, "error": {"code": "INVALID_INPUT", "message": "jobId must be a number"}});
if let Ok(bytes) = serde_json::to_vec(&resp) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&resp) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize INVALID_INPUT response: {e}"),
}
}
continue;
@@ -443,20 +458,26 @@ async fn handle_ws(
Some(job_id) => {
reader_jobs.lock().await.remove(&job_id);
let resp = json!({"id": req_id, "result": {"unsubscribed": job_id}});
if let Ok(bytes) = serde_json::to_vec(&resp) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&resp) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize unsubscribe response: {e}"),
}
}
None => {
let resp = json!({"id": req_id, "error": {"code": "INVALID_INPUT", "message": "jobId must be a number"}});
if let Ok(bytes) = serde_json::to_vec(&resp) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&resp) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize INVALID_INPUT response: {e}"),
}
}
}
@@ -479,11 +500,14 @@ async fn handle_ws(
Err(e) => json!({"id": req_id, "error": {"code": e.0, "message": e.1}}),
}
};
if let Ok(bytes) = serde_json::to_vec(&response) {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
match serde_json::to_vec(&response) {
Ok(bytes) => {
if let Err(e) = reader_tx.send(bytes).await {
tracing::trace!("RPC response channel closed: {e}");
break;
}
}
Err(e) => tracing::warn!("failed to serialize RPC response: {e}"),
}
}
@@ -647,7 +671,10 @@ async fn rpc_get_target() -> RpcResult {
let sz = crate::utils::subnet_host_count(&net).min(u64::MAX as u128) as u64;
(sz > 1, sz.max(1))
}
Err(_) => (false, 1),
Err(e) => {
tracing::debug!("subnet size estimate: parse failed for {:?}: {}", t, e);
(false, 1)
}
},
Some(_) => (false, 1),
};
@@ -894,7 +921,15 @@ async fn rpc_set_option(params: &Value) -> RpcResult {
}
let mut set_count = 0usize;
for (key, val) in obj {
let value = val.as_str().unwrap_or("");
let value = match val.as_str() {
Some(v) => v,
None => {
return Err(rpc_err(
"INVALID_INPUT",
format!("Option '{}' value must be a string", key),
));
}
};
if key.is_empty() || key.len() > 256 {
return Err(rpc_err("INVALID_INPUT", format!("Option key '{}' invalid (1-256 chars)", key)));
}