v3.4.1: CLI-only Rust harness — interactive wizard, smart selection, tool doctrine, Typst, status

- Remove Rust web server (axum/tower-http); CLI-only binary
- Verbose logging (-v) + unique run-id output folder runs/ns-<ts>-<target>/
- status.json lifecycle (running → complete) + ✓ COMPLETE summary
- Interactive wizard when run with no args; detailed --help with testphp/DVWA examples + Kali tip
- Tool-usage doctrine injected into recon/exploit prompts: curl + rustscan/nmap
  (apt/brew/cargo install guidance) + browser via Playwright when present, else curl
- Smart recon-aware selection: map recon signals → agent categories, only run
  matching agents; heuristic fallback when LLM selection is empty
- Cross-model false-positive validation: voting prefers a model other than the finder
- Playwright MCP auto-provision (npx) + per-backend support (claude/codex; gemini/grok degrade)
- Gemini provider (API + gemini CLI subscription)
- Typst report (report.typ + compiled report.pdf) via blank structured template
- Lenient finding parsing (confidence as word/number) — fixes empty-results bug
- bump version 3.4.0 -> 3.4.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 19:34:13 -03:00
parent e565270f43
commit 96f00c1c68
15 changed files with 512 additions and 969 deletions
+138 -42
View File
@@ -1,26 +1,39 @@
//! NeuroSploit v3.4.0single binary: `serve` (web dashboard) or `run` (CLI).
mod web;
//! NeuroSploit v3.4.1CLI: `run` (black-box) / `whitebox` (source) / `agents` / `models`.
use clap::{Parser, Subcommand};
use harness::{agents, models::ModelRef, pool::ModelPool, types::RunConfig, RunOutput};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(name = "neurosploit", version, about = "NeuroSploit v3.4.0 — multi-model autonomous pentest harness")]
#[command(
name = "neurosploit",
version,
about = "NeuroSploit v3.4.1 — multi-model autonomous pentest harness",
long_about = "NeuroSploit v3.4.1 — a Rust multi-model harness that drives a pool of LLMs \
(API key or local subscription: Claude/Codex/Gemini/Grok) to autonomously test a target. \
After recon it INTELLIGENTLY selects only the agents matching the discovered surface, runs \
them in parallel, then validates every finding by cross-model voting before reporting.\n\n\
Run with NO arguments for an interactive wizard.\n\n\
EXAMPLES:\n \
# Black-box against a known test site (subscription, Opus, browser via Playwright if present)\n \
neurosploit run http://testphp.vulnweb.com/ --subscription --model anthropic:claude-opus-4-8 --mcp -v\n\n \
# Black-box via API keys with a multi-model voting panel\n \
neurosploit run http://testphp.vulnweb.com/ --model anthropic:claude-opus-4-8 --model openai:gpt-5.1 --vote-n 3\n\n \
# White-box source review of a cloned repo (DVWA)\n \
git clone https://github.com/digininja/DVWA /tmp/DVWA\n \
neurosploit whitebox /tmp/DVWA --subscription --model anthropic:claude-opus-4-8 -v\n\n \
# Offline pipeline self-test (no keys/login)\n \
neurosploit run http://testphp.vulnweb.com/ --offline\n\n\
TIP: run inside Kali Linux (or `docker run -it kalilinux/kali-rolling`) so curl/nmap/rustscan/ffuf are available."
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Start the web dashboard.
Serve {
#[arg(long, default_value_t = 8788)]
port: u16,
},
/// Run an engagement from the CLI.
/// Black-box: recon → intelligent agent selection → exploit → vote → report.
Run {
url: String,
/// Models as provider:model (repeatable). First is primary; rest fail over + vote.
@@ -30,20 +43,21 @@ enum Cmd {
max_agents: usize,
#[arg(long, default_value_t = 3)]
vote_n: usize,
/// Exercise the pipeline without calling any model API.
#[arg(long)]
offline: bool,
/// Use local agentic CLI subscriptions (Claude Code / Codex / Grok)
/// instead of HTTP API keys.
/// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok login).
#[arg(long)]
subscription: bool,
/// Enable Playwright MCP (browser proof) on the subscription/CLI path.
/// Enable Playwright MCP (auto-installed if missing; backends that don't
/// support MCP fall back to their built-in tools).
#[arg(long)]
mcp: bool,
/// Verbose: log each agent as it launches, recon, and votes.
#[arg(short, long)]
verbose: bool,
},
/// White-box: analyse a local repository's source code for vulnerabilities.
Whitebox {
/// Path to the repository to analyse.
path: String,
#[arg(long = "model")]
models: Vec<String>,
@@ -55,6 +69,8 @@ enum Cmd {
offline: bool,
#[arg(long)]
subscription: bool,
#[arg(short, long)]
verbose: bool,
},
/// Show agent library counts.
Agents,
@@ -62,8 +78,7 @@ enum Cmd {
Models,
}
/// Locate the repo root that holds `agents_md/` (walk up from CWD, then fall
/// back to the crate's compile-time location).
/// Locate the repo root that holds `agents_md/`.
fn find_base() -> PathBuf {
if let Ok(b) = std::env::var("NEUROSPLOIT_BASE") {
return PathBuf::from(b);
@@ -80,7 +95,6 @@ fn find_base() -> PathBuf {
}
}
}
// crate is at <root>/neurosploit-rs/app → root is two levels up
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
@@ -93,10 +107,18 @@ async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let base = find_base();
match cli.cmd {
let cmd = match cli.cmd {
Some(c) => c,
None => interactive(&base).await?, // no args → wizard
};
match cmd {
Cmd::Agents => {
let lib = agents::load(&base);
println!("{{\"vulns\":{},\"meta\":{},\"total\":{}}}", lib.vulns.len(), lib.meta.len(), lib.total());
println!(
"{{\"vulns\":{},\"recon\":{},\"code\":{},\"meta\":{},\"total\":{}}}",
lib.vulns.len(), lib.recon.len(), lib.code.len(), lib.meta.len(), lib.total()
);
}
Cmd::Models => {
for p in harness::providers() {
@@ -106,55 +128,76 @@ async fn main() -> anyhow::Result<()> {
}
}
}
Cmd::Run { url, models, max_agents, vote_n, offline, subscription, mcp } => {
Cmd::Run { url, models, max_agents, vote_n, offline, subscription, mcp, verbose } => {
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
let mut cfg = RunConfig::new(&url);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
if !models.is_empty() {
cfg.models = models;
}
let out = run_engagement(&base, cfg, mcp, false).await?;
print_findings(&out);
}
Cmd::Whitebox { path, models, max_agents, vote_n, offline, subscription } => {
Cmd::Whitebox { path, models, max_agents, vote_n, offline, subscription, verbose } => {
let mut cfg = RunConfig::new(&path);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
if !models.is_empty() {
cfg.models = models;
}
let out = run_engagement(&base, cfg, false, true).await?;
print_findings(&out);
}
Cmd::Serve { port } => {
web::serve(base, port).await?;
}
}
Ok(())
}
/// Shared engagement runner for CLI `run` / `whitebox`.
/// Shared engagement runner for `run` / `whitebox`.
async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bool) -> anyhow::Result<RunOutput> {
let lib = agents::load(base);
let workdir = base.join("runs").join(format!("{}-{}", sanitize(&cfg.target), now_ts()));
// Unique, sortable run id → runs/<id>/
let run_id = format!("ns-{}-{}", now_ts(), sanitize(&cfg.target));
let workdir = base.join("runs").join(&run_id);
std::fs::create_dir_all(&workdir).ok();
cfg.workdir = Some(workdir.display().to_string());
cfg.rl_path = Some(base.join("data").join("rl_state_rs.json").display().to_string());
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
println!(" ┌─ NeuroSploit v3.4.1");
println!(" │ run id : {run_id}");
println!(" │ target : {}", cfg.target);
println!(" │ models : {}", cfg.models.join(", "));
println!(" │ output : {}", workdir.display());
println!(" └─ mode : {}{}{}",
if whitebox { "white-box" } else { "black-box" },
if cfg.subscription { " · subscription" } else { " · api" },
if mcp { " · mcp" } else { "" });
// Playwright MCP: only for backends that support it; auto-provision if asked.
let mcp_config = if mcp && cfg.subscription {
match harness::write_mcp_config(&workdir) {
Ok(p) => {
println!(" [*] Playwright MCP enabled → {}", p.display());
Some(p.display().to_string())
}
Err(e) => {
eprintln!(" [!] MCP config failed: {e}");
None
let providers: Vec<String> = cfg.models.iter().map(|m| ModelRef::parse(m).provider).collect();
if providers.iter().any(|p| harness::mcp_supported(p)) {
match harness::ensure_playwright_mcp() {
Ok(()) => match harness::write_mcp_config(&workdir) {
Ok(p) => {
println!(" [*] Playwright MCP ready → {}", p.display());
Some(p.display().to_string())
}
Err(e) => { eprintln!(" [!] MCP config failed: {e}"); None }
},
Err(e) => { eprintln!(" [!] Playwright MCP unavailable ({e}); using built-in tools"); None }
}
} else {
eprintln!(" [!] selected backend(s) don't support MCP; using built-in tools");
None
}
} else {
None
@@ -175,6 +218,14 @@ async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bo
harness::run(cfg, &lib, &pool, tx).await
};
let _ = printer.await;
// Final report via Typst (PDF if the `typst` binary is present) + HTML/MD already written.
match harness::report::typst_report(&out.target, &out.findings, &workdir) {
Ok(p) => println!(" [*] report → {}", p.display()),
Err(e) => eprintln!(" [!] typst report skipped: {e}"),
}
write_status(&workdir, "complete", &format!("\"findings\":{},\"agents_ran\":{}", out.findings.len(), out.agents_ran.len()));
println!(" ✓ COMPLETE — {} validated finding(s) · status: {}/status.json", out.findings.len(), workdir.display());
Ok(out)
}
@@ -189,16 +240,61 @@ fn print_findings(out: &RunOutput) {
fn sanitize(s: &str) -> String {
let s = s.replace("https://", "").replace("http://", "");
let mut o: String = s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
o.truncate(50);
o.truncate(40);
let o = o.trim_matches('_').to_string();
if o.is_empty() {
"target".into()
} else {
o
}
if o.is_empty() { "target".into() } else { o }
}
fn now_ts() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn write_status(workdir: &Path, state: &str, extra: &str) {
let p = workdir.join("status.json");
let _ = std::fs::write(&p, format!("{{\"state\":\"{state}\",\"ts\":{}{}}}", now_ts(),
if extra.is_empty() { String::new() } else { format!(",{extra}") }));
}
fn prompt(q: &str, default: &str) -> String {
use std::io::Write;
print!(" {q}{}: ", if default.is_empty() { String::new() } else { format!(" [{default}]") });
std::io::stdout().flush().ok();
let mut s = String::new();
std::io::stdin().read_line(&mut s).ok();
let s = s.trim().to_string();
if s.is_empty() { default.to_string() } else { s }
}
/// Interactive wizard launched when `neurosploit` is run with no subcommand.
async fn interactive(base: &Path) -> anyhow::Result<Cmd> {
let lib = agents::load(base);
let backends = harness::installed_cli_backends();
println!("\n ┌────────────────────────────────────────────┐");
println!(" │ NeuroSploit v3.4.1 — interactive │");
println!(" └────────────────────────────────────────────┘");
println!(" agents: {} · detected CLI logins: {}\n",
lib.total(), if backends.is_empty() { "none".into() } else { backends.join(", ") });
let mode = prompt("Mode — (b)lack-box URL or (w)hite-box repo?", "b").to_lowercase();
let whitebox = mode.starts_with('w');
let target = if whitebox {
prompt("Repository path", "/tmp/DVWA")
} else {
prompt("Target URL", "http://testphp.vulnweb.com/")
};
let model = prompt("Model (provider:model)", "anthropic:claude-opus-4-8");
let sub = prompt("Use subscription login (no API key)? (y/n)", "y").to_lowercase().starts_with('y');
let mcp = if whitebox { false } else {
prompt("Use Playwright MCP browser if available? (y/n)", "y").to_lowercase().starts_with('y')
};
let max_agents: usize = prompt("Max agents (0 = all matching)", "5").parse().unwrap_or(5);
let vote_n: usize = prompt("Validator votes (N)", "3").parse().unwrap_or(3);
let models = vec![model];
Ok(if whitebox {
Cmd::Whitebox { path: target, models, max_agents, vote_n, offline: false, subscription: sub, verbose: true }
} else {
Cmd::Run { url: target, models, max_agents, vote_n, offline: false, subscription: sub, mcp, verbose: true }
})
}
-236
View File
@@ -1,236 +0,0 @@
//! Axum web dashboard for the v3.4.0 harness.
use axum::{
extract::{Path, State},
response::Html,
routing::{get, post},
Json, Router,
};
use harness::{agents, models::ModelRef, pool::ModelPool, report, types::RunConfig};
use serde_json::{json, Value};
use std::{
collections::HashMap,
path::PathBuf,
sync::{Arc, Mutex},
};
struct RunState {
log: Vec<String>,
done: bool,
result: Option<Value>,
report: Option<String>,
}
pub struct AppState {
base: PathBuf,
runs: Mutex<HashMap<String, RunState>>,
}
pub async fn serve(base: PathBuf, port: u16) -> anyhow::Result<()> {
let state = Arc::new(AppState { base, runs: Mutex::new(HashMap::new()) });
let app = Router::new()
.route("/", get(index))
.route("/api/info", get(info))
.route("/api/agents", get(agents_list))
.route("/api/models", get(models_list))
.route("/api/run", post(run))
.route("/api/status/:id", get(status))
.route("/report/:id", get(report_html))
.with_state(state);
let addr = format!("127.0.0.1:{port}");
println!("NeuroSploit v3.4.0 dashboard → http://{addr}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn index() -> Html<&'static str> {
Html(include_str!("../web/index.html"))
}
async fn info(State(st): State<Arc<AppState>>) -> Json<Value> {
let lib = agents::load(&st.base);
let provs: Vec<Value> = harness::providers()
.iter()
.map(|p| json!({"key": p.key, "label": p.label, "kind": p.kind, "models": p.models}))
.collect();
Json(json!({
"version": "3.4.0",
"agents": {"vulns": lib.vulns.len(), "meta": lib.meta.len(), "recon": lib.recon.len(), "code": lib.code.len(), "total": lib.total()},
"providers": provs,
"cli_backends": harness::installed_cli_backends(),
}))
}
async fn agents_list(State(st): State<Arc<AppState>>) -> Json<Value> {
let lib = agents::load(&st.base);
let v: Vec<Value> = lib
.vulns
.iter()
.chain(lib.recon.iter())
.chain(lib.code.iter())
.chain(lib.meta.iter())
.map(|a| json!({"name": a.name, "title": a.title, "cwe": a.cwe, "kind": a.kind}))
.collect();
Json(json!({ "agents": v }))
}
async fn models_list() -> Json<Value> {
let provs: Vec<Value> = harness::providers()
.iter()
.map(|p| json!({"key": p.key, "label": p.label, "kind": p.kind, "models": p.models}))
.collect();
Json(json!({ "providers": provs }))
}
fn norm(u: &str) -> String {
if u.starts_with("http") {
u.to_string()
} else {
format!("https://{u}")
}
}
async fn run(State(st): State<Arc<AppState>>, Json(body): Json<Value>) -> Json<Value> {
let id = uuid::Uuid::new_v4().to_string();
st.runs
.lock()
.unwrap()
.insert(id.clone(), RunState { log: vec![], done: false, result: None, report: None });
let st2 = st.clone();
let id2 = id.clone();
tokio::spawn(async move {
let base = st2.base.clone();
let mut targets: Vec<String> = Vec::new();
if let Some(arr) = body.get("targets").and_then(|v| v.as_array()) {
for t in arr {
if let Some(s) = t.as_str() {
if !s.trim().is_empty() {
targets.push(norm(s.trim()));
}
}
}
}
if targets.is_empty() {
if let Some(u) = body.get("url").and_then(|v| v.as_str()) {
if !u.trim().is_empty() {
targets.push(norm(u.trim()));
}
}
}
let models: Vec<String> = body
.get("models")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let vote_n = body.get("vote_n").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
let max_agents = body.get("max_agents").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let offline = body.get("offline").and_then(|v| v.as_bool()).unwrap_or(false);
let subscription = body.get("subscription").and_then(|v| v.as_bool()).unwrap_or(false);
let mcp = body.get("mcp").and_then(|v| v.as_bool()).unwrap_or(false);
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("web").to_string();
// Whitebox uses a repo path instead of URLs.
if mode == "whitebox" {
if let Some(p) = body.get("repo").and_then(|v| v.as_str()) {
if !p.trim().is_empty() {
targets = vec![p.trim().to_string()];
}
}
}
let lib = agents::load(&base);
let refs: Vec<ModelRef> = if models.is_empty() {
vec![ModelRef::parse("anthropic:claude-opus-4-8")]
} else {
models.iter().map(|s| ModelRef::parse(s)).collect()
};
let mcp_config = if mcp && subscription {
harness::write_mcp_config(&base.join("runs").join("_mcp")).ok().map(|p| p.display().to_string())
} else {
None
};
let pool = ModelPool::with_auth(refs, 8, subscription, mcp_config);
let rl_path = base.join("data").join("rl_state_rs.json").display().to_string();
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(256);
let stf = st2.clone();
let idf = id2.clone();
let fwd = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
if let Ok(mut g) = stf.runs.lock() {
if let Some(r) = g.get_mut(&idf) {
r.log.push(line);
}
}
}
});
let mut all_findings = Vec::new();
let mut all_ran = Vec::new();
for url in &targets {
let mut cfg = RunConfig::new(url);
cfg.models = if models.is_empty() {
vec!["anthropic:claude-opus-4-8".into()]
} else {
models.clone()
};
cfg.vote_n = vote_n;
cfg.max_agents = max_agents;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.rl_path = Some(rl_path.clone());
cfg.workdir = Some(base.join("runs").join(format!("{}-{}", slug(url), now_ts())).display().to_string());
let _ = tx.send(format!("=== {}: {url} ===", if mode == "whitebox" { "whitebox repo" } else { "target" })).await;
let out = if mode == "whitebox" {
harness::run_whitebox(cfg, &lib, &pool, tx.clone()).await
} else {
harness::run(cfg, &lib, &pool, tx.clone()).await
};
all_findings.extend(out.findings);
all_ran.extend(out.agents_ran);
}
drop(tx);
let _ = fwd.await;
let report_html = report::html(targets.first().map(|s| s.as_str()).unwrap_or(""), &all_findings);
let result = json!({"findings": all_findings, "agents_ran": all_ran, "targets": targets});
if let Ok(mut g) = st2.runs.lock() {
if let Some(r) = g.get_mut(&id2) {
r.result = Some(result);
r.report = Some(report_html);
r.done = true;
}
}
});
Json(json!({ "run_id": id }))
}
async fn status(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Json<Value> {
let g = st.runs.lock().unwrap();
match g.get(&id) {
Some(r) => Json(json!({"log": r.log, "done": r.done, "result": r.result, "has_report": r.report.is_some()})),
None => Json(json!({"error": "unknown run"})),
}
}
async fn report_html(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Html<String> {
let g = st.runs.lock().unwrap();
Html(g.get(&id).and_then(|r| r.report.clone()).unwrap_or_else(|| "<h1>no report</h1>".into()))
}
fn slug(s: &str) -> String {
let s = s.replace("https://", "").replace("http://", "");
let mut o: String = s.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
o.truncate(50);
let o = o.trim_matches('_').to_string();
if o.is_empty() { "target".into() } else { o }
}
fn now_ts() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}