diff --git a/neurosploit-rs/Cargo.lock b/neurosploit-rs/Cargo.lock index 3d1d7ef..1fc57a6 100644 --- a/neurosploit-rs/Cargo.lock +++ b/neurosploit-rs/Cargo.lock @@ -67,81 +67,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "base64", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base64" version = "0.22.1" @@ -154,27 +85,12 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.0" @@ -249,41 +165,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -408,16 +289,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -440,22 +311,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", + "r-efi", "wasip2", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - [[package]] name = "heck" version = "0.5.0" @@ -501,12 +361,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.10.1" @@ -520,7 +374,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -732,24 +585,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "mio" version = "1.2.1" @@ -763,23 +604,20 @@ dependencies = [ [[package]] name = "neurosploit" -version = "3.4.0" +version = "3.4.1" dependencies = [ "anyhow", - "axum", "clap", "futures", "neurosploit-harness", "serde", "serde_json", "tokio", - "tower-http 0.5.2", - "uuid", ] [[package]] name = "neurosploit-harness" -version = "3.4.0" +version = "3.4.1" dependencies = [ "anyhow", "futures", @@ -879,7 +717,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -894,13 +732,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -935,41 +773,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -979,16 +790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1067,7 +869,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower", - "tower-http 0.6.11", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -1201,17 +1003,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1224,17 +1015,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1322,33 +1102,13 @@ dependencies = [ "syn", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1425,18 +1185,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tower" version = "0.5.3" @@ -1450,23 +1198,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", ] [[package]] @@ -1505,7 +1236,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-core", ] @@ -1525,30 +1255,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.6", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -1573,12 +1279,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -1591,23 +1291,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" diff --git a/neurosploit-rs/Cargo.toml b/neurosploit-rs/Cargo.toml index e9aa091..7ccf9dc 100644 --- a/neurosploit-rs/Cargo.toml +++ b/neurosploit-rs/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/harness", "app"] resolver = "2" [workspace.package] -version = "3.4.0" +version = "3.4.1" edition = "2021" license = "MIT" repository = "https://github.com/JoasASantos/NeuroSploit" diff --git a/neurosploit-rs/app/Cargo.toml b/neurosploit-rs/app/Cargo.toml index 0cb2b19..1b0d85e 100644 --- a/neurosploit-rs/app/Cargo.toml +++ b/neurosploit-rs/app/Cargo.toml @@ -15,7 +15,4 @@ serde_json.workspace = true tokio.workspace = true anyhow.workspace = true futures.workspace = true -axum = { version = "0.7", features = ["ws"] } -tower-http = { version = "0.5", features = ["cors"] } clap = { version = "4", features = ["derive"] } -uuid = { version = "1", features = ["v4"] } diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index c424f90..56414b9 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -1,26 +1,39 @@ -//! NeuroSploit v3.4.0 — single binary: `serve` (web dashboard) or `run` (CLI). - -mod web; +//! NeuroSploit v3.4.1 — CLI: `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, } #[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, @@ -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 /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 { let lib = agents::load(base); - let workdir = base.join("runs").join(format!("{}-{}", sanitize(&cfg.target), now_ts())); + + // Unique, sortable run id → runs// + 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 = 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 { + 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 } + }) +} diff --git a/neurosploit-rs/app/src/web.rs b/neurosploit-rs/app/src/web.rs deleted file mode 100644 index 88fe43e..0000000 --- a/neurosploit-rs/app/src/web.rs +++ /dev/null @@ -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, - done: bool, - result: Option, - report: Option, -} - -pub struct AppState { - base: PathBuf, - runs: Mutex>, -} - -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>) -> Json { - let lib = agents::load(&st.base); - let provs: Vec = 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>) -> Json { - let lib = agents::load(&st.base); - let v: Vec = 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 { - let provs: Vec = 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>, Json(body): Json) -> Json { - 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 = 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 = 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 = 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::(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, State(st): State>) -> Json { - 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, State(st): State>) -> Html { - let g = st.runs.lock().unwrap(); - Html(g.get(&id).and_then(|r| r.report.clone()).unwrap_or_else(|| "

no report

".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) -} diff --git a/neurosploit-rs/app/web/index.html b/neurosploit-rs/app/web/index.html deleted file mode 100644 index dfc1a8a..0000000 --- a/neurosploit-rs/app/web/index.html +++ /dev/null @@ -1,327 +0,0 @@ - - - - - -NeuroSploit - - - -
- - -
-
-

Engagement

Configure and launch an autonomous run
-
online
-
- - -
-
- - -
-
-
-
-

Target

-
One or more URLs — the harness recons each, then intelligently selects matching agents.
-
- -
-
-
-
-
- - - -
-
-
-
-
-
-
-

Model panel

-
1st = primary · others fail over & form the validator jury.
-
-
-
-
-
-

Live execution

-
Recon → intelligent agent selection → parallel exploitation → N-model voting → report. Artifacts saved to runs/.
-
— idle. Launch an engagement to stream activity. —
-
-
- - -
-
-

Validated findings

-
Only findings confirmed by multi-model adversarial voting appear here.
-
-
No findings yet — run an engagement.
-
-
- - -
-
-

Report

-
HTML report + JSON/MD artifacts for reuse by other tools/AIs.
-
Run an engagement to generate a report.
-
-
- - -
-
-

Agent library

-
-
- - - - - - -
-
-
-
- - -
-
-

Providers & models

-
Use via API key or subscription (local CLI login). CLI-capable providers are tagged.
-
-
-
-
-
- - - diff --git a/neurosploit-rs/cj_proof2.png b/neurosploit-rs/cj_proof2.png new file mode 100644 index 0000000..31bbd4d Binary files /dev/null and b/neurosploit-rs/cj_proof2.png differ diff --git a/neurosploit-rs/clickjack_proof.png b/neurosploit-rs/clickjack_proof.png new file mode 100644 index 0000000..e0908f2 Binary files /dev/null and b/neurosploit-rs/clickjack_proof.png differ diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index d3622b1..a02f3c9 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -1,4 +1,4 @@ -//! NeuroSploit v3.4.0 harness — a robust multi-model runtime for the +//! NeuroSploit v3.4.1 harness — a robust multi-model runtime for the //! markdown-driven autonomous pentest engine. //! //! The harness loads the `agents_md/` library, drives a *pool* of LLM models @@ -16,8 +16,8 @@ pub mod types; pub use agents::{Agent, Library}; pub use models::{ - cli_binary_for, installed_cli_backends, provider_for, providers, write_mcp_config, ChatClient, - ModelRef, Provider, + cli_binary_for, ensure_playwright_mcp, installed_cli_backends, mcp_supported, provider_for, + providers, write_mcp_config, ChatClient, ModelRef, Provider, }; pub use pipeline::{run_whitebox, RunOutput}; pub use pipeline::run; diff --git a/neurosploit-rs/crates/harness/src/models.rs b/neurosploit-rs/crates/harness/src/models.rs index f76a326..5479126 100644 --- a/neurosploit-rs/crates/harness/src/models.rs +++ b/neurosploit-rs/crates/harness/src/models.rs @@ -146,20 +146,23 @@ impl ChatClient { let mut cmd = Command::new(bin); match bin { // Claude Code headless print mode (uses the Claude subscription login). + // Tool autonomy is always enabled so the agent can use its built-in + // tools (Bash/curl/etc.) to actually probe the target — Playwright MCP + // is an *optional* add-on, not a requirement. "claude" => { - cmd.arg("-p").arg("--model").arg(model); + cmd.arg("-p").arg("--model").arg(model).arg("--dangerously-skip-permissions"); + // Required to allow tool autonomy when running as root. + cmd.env("IS_SANDBOX", "1"); if let Some(mcp) = mcp_config { - cmd.arg("--mcp-config").arg(mcp).arg("--dangerously-skip-permissions"); - // Required to allow tool autonomy when running as root. - cmd.env("IS_SANDBOX", "1"); + cmd.arg("--mcp-config").arg(mcp); } } // Codex non-interactive exec (uses the ChatGPT/Codex login), prompt on stdin. "codex" => { - cmd.arg("exec").arg("--model").arg(model); + cmd.arg("exec").arg("--model").arg(model) + .arg("--dangerously-bypass-approvals-and-sandbox"); if let Some(mcp) = mcp_config { - cmd.arg("--config").arg(format!("mcp_config_file={mcp}")) - .arg("--dangerously-bypass-approvals-and-sandbox"); + cmd.arg("--config").arg(format!("mcp_config_file={mcp}")); } cmd.arg("-"); } @@ -173,13 +176,17 @@ impl ChatClient { } _ => {} } - cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| anyhow!("spawn {} failed: {}", bin, e))?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(prompt.as_bytes()).await?; // Drop closes stdin so the CLI processes the prompt and exits. } - let out = child.wait_with_output().await?; + // Cap a single agentic CLI turn so a stuck tool-loop can't hang the run. + let out = match tokio::time::timeout(Duration::from_secs(600), child.wait_with_output()).await { + Ok(r) => r?, + Err(_) => return Err(anyhow!("{} subscription CLI timed out after 600s", bin)), + }; let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&out.stderr); if !out.status.success() { @@ -229,6 +236,35 @@ pub fn installed_cli_backends() -> Vec<&'static str> { ["claude", "codex", "grok", "gemini"].into_iter().filter(|b| binary_in_path(b)).collect() } +/// Does this provider's agentic CLI accept a Playwright MCP config? +/// Claude Code and Codex do; Gemini/Grok CLIs don't take an MCP-config flag, so +/// they fall back to their own built-in tools. +pub fn mcp_supported(provider: &str) -> bool { + matches!(provider, "anthropic" | "openai") +} + +/// Best-effort ensure the Playwright MCP server is available locally. Requires +/// `npx`; pre-warms `@playwright/mcp` so the first agent call isn't a cold start. +/// Returns Err with a clear reason when it can't be provisioned (caller then +/// degrades to built-in tools). +pub fn ensure_playwright_mcp() -> Result<()> { + if !binary_in_path("npx") { + return Err(anyhow!("npx (Node.js) not found — install Node to use Playwright MCP")); + } + // `npx -y @playwright/mcp@latest --help` installs the package into the npx + // cache on first run; ignore non-zero exit (some versions lack --help) as long + // as the package resolves. + let out = std::process::Command::new("npx") + .args(["-y", "@playwright/mcp@latest", "--help"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match out { + Ok(_) => Ok(()), + Err(e) => Err(anyhow!("could not provision @playwright/mcp via npx: {e}")), + } +} + /// Write a Playwright `.mcp.json` into `dir` and return its path, so the agentic /// CLI can drive a real browser (DOM/JS/network/screenshots) during execution. pub fn write_mcp_config(dir: &std::path::Path) -> std::io::Result { diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index f42c0e9..9d9985a 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -11,6 +11,7 @@ use tokio::sync::mpsc::Sender; /// Result of an engagement run. #[derive(Default, Serialize)] pub struct RunOutput { + pub target: String, pub findings: Vec, pub agents_ran: Vec, pub candidates: usize, @@ -19,7 +20,28 @@ pub struct RunOutput { pub artifacts: Vec, } -const RECON_SYS: &str = "You are a web recon specialist. Map the target's attack surface and reply with a compact JSON object (tech, endpoints, auth, apis, ai_features). No prose."; +const RECON_SYS: &str = "You are a web recon specialist on an AUTHORIZED engagement. You have shell tools (curl etc.) — actively fetch the target, enumerate pages/params, and map the real attack surface. Do not ask for permission; proceed. Reply with a compact JSON object (tech, endpoints, params, auth, apis). No prose."; + +/// Tool-usage doctrine prepended to recon/exploit prompts so the agent knows +/// exactly what it may use. Best run on Kali Linux (or the Kali Docker image), +/// where these tools are preinstalled. +fn tool_doctrine(mcp_on: bool) -> String { + let browser = if mcp_on { + "A Playwright MCP browser IS available — use it for JS-heavy pages, DOM/JS execution, and to PROVE client-side issues (e.g. XSS firing); capture screenshots as evidence." + } else { + "No browser MCP is available — use `curl` (and `wget`) for all HTTP interaction; render/inspect responses directly." + }; + format!( + "TOOLING (authorized; best on Kali Linux or the kalilinux/kali-rolling Docker image):\n\ + - HTTP: `curl` (headers, methods, params, cookies), `wget`.\n\ + - Ports/services: `rustscan` if present, else `nmap`; if neither is installed you may \ + install via apt (`apt install -y nmap`), brew, or cargo (`cargo install rustscan`) — \ + otherwise probe common ports with `curl`/`nc`.\n\ + - Content/params: `ffuf`, `gobuster`, `gau`, `katana` when available.\n\ + - {browser}\n\ + Use only what is installed; degrade gracefully. Never run destructive or DoS actions.\n\n" + ) +} const VOTE_SYS: &str = "You are an adversarial security validator. Decide if the candidate finding is a REAL, reproducible, exploitable vulnerability with proof. Reply with JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}. Default to rejected when uncertain."; const CODE_VOTE_SYS: &str = "You are an adversarial source-code reviewer. Decide if the reported issue is a REAL vulnerability in the provided code (reachable, exploitable, not a false positive). Reply JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}."; @@ -40,9 +62,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender { let _ = tx.send(format!("recon complete via {}", m.label())).await; + if cfg.verbose { + let snip: String = t.chars().take(280).collect(); + let _ = tx.send(format!(" recon> {}", snip.replace('\n', " "))).await; + } t } Err(e) => { @@ -63,22 +90,24 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender = { - let mut sel: Vec = if chosen.is_empty() { - ranked.clone() - } else { - ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect() - }; + let selected: Vec = if !chosen.is_empty() { + let sel: Vec = + ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect(); if sel.is_empty() { - sel = ranked.clone(); + heuristic_select(&ranked, &recon, cap) + } else { + sel.into_iter().take(cap).collect() } - sel.into_iter().take(cap).collect() + } else { + // LLM selection failed/empty → recon-keyword heuristic, not a blind flat list. + let _ = tx.send("selection empty — using recon-keyword heuristic".into()).await; + heuristic_select(&ranked, &recon, cap) }; let _ = tx .send(format!("intelligently selected {} agent(s) matching recon: {}", selected.len(), @@ -87,6 +116,8 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender)> = stream::iter(selected.iter().cloned()) .map(|ag| { let target = target.clone(); @@ -94,10 +125,18 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender { let f = extract_findings(&text, &ag.name); @@ -145,7 +184,7 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S if cfg.offline || bytes == 0 { let artifacts = persist(&cfg, "{}", &context, &[]); - return RunOutput { findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts }; + return RunOutput { target: cfg.target.clone(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts }; } let raw: Vec<(String, String, Vec)> = stream::iter(selected.iter().cloned()) @@ -200,7 +239,12 @@ async fn select_agents(pool: &ModelPool, recon: &str, catalog: &[Agent], tx: &Se match pool.complete(SELECT_SYS, &user).await { Ok((m, text)) => { let names = parse_string_array(&text); - let _ = tx.send(format!("agent selection via {} → {} agent(s) chosen", m.label(), names.len())).await; + if names.is_empty() { + let preview: String = text.chars().take(120).collect(); + let _ = tx.send(format!("agent selection via {} returned no parseable list ({} chars): {}", m.label(), text.len(), preview.replace('\n', " "))).await; + } else { + let _ = tx.send(format!("agent selection via {} → {} agent(s) chosen", m.label(), names.len())).await; + } names } Err(e) => { @@ -217,16 +261,88 @@ fn parse_string_array(text: &str) -> Vec { } } +/// Fallback agent selection when the LLM selector fails: score each agent by +/// keyword overlap between its name/title and the recon text, always seed a +/// black-box baseline of high-yield web classes, and take the top `cap`. +fn heuristic_select(ranked: &[Agent], recon: &str, cap: usize) -> Vec { + const BASELINE: &[&str] = &[ + "sqli_error", "sqli_blind", "sqli_union", "xss_reflected", "xss_stored", "xss_dom", + "command_injection", "lfi", "path_traversal", "ssrf", "idor", "open_redirect", + "auth_bypass", "csrf", "ssti", "file_upload", "xxe", "information_disclosure", + "security_headers", "cors_misconfig", + ]; + let r = recon.to_lowercase(); + // Recon signal → agent-name substrings. Only agents whose surface the recon + // actually identified get the signal boost; the rest rely on the baseline. + let signals: &[(&str, &[&str])] = &[ + ("graphql", &["graphql"]), + ("jwt", &["jwt"]), + ("oauth", &["oauth", "oidc", "saml"]), + ("\"jwt\"", &["jwt"]), + ("api", &["api_", "bola", "bfla", "idor", "mass_assign", "rate_limit"]), + ("upload", &["file_upload", "zip_slip"]), + ("websocket", &["websocket"]), + ("\"ws\"", &["websocket"]), + ("graphql", &["graphql"]), + ("aws", &["aws_", "s3_", "imds", "cloud_"]), + ("gcp", &["gcp_", "gcs_", "metadata"]), + ("azure", &["azure_"]), + ("kubernetes", &["k8s_", "kubelet"]), + ("docker", &["docker_", "container_"]), + ("ai_features", &["llm_", "prompt_injection", "rag", "vector_db"]), + ("chat", &["llm_", "prompt_injection"]), + ("jinja", &["ssti"]), + ("flask", &["ssti", "ssrf", "command_injection"]), + ("php", &["lfi", "rfi", "sqli", "command_injection"]), + ("template", &["ssti", "csti"]), + ("redirect", &["open_redirect"]), + ("login", &["auth_bypass", "brute_force", "sqli", "default_credentials"]), + ("search", &["xss", "sqli"]), + ("cache", &["cache", "smuggl"]), + ]; + let mut scored: Vec<(i32, &Agent)> = ranked + .iter() + .map(|a| { + let mut score = 0; + if BASELINE.contains(&a.name.as_str()) { + score += 4; + } + // recon-signal mapping: boost agents matching identified surface + for (sig, names) in signals { + if r.contains(sig) && names.iter().any(|n| a.name.contains(n)) { + score += 6; + } + } + // direct keyword overlap with recon text + for tok in a.name.split('_') { + if tok.len() >= 4 && r.contains(tok) { + score += 2; + } + } + (score, a) + }) + .collect(); + scored.sort_by(|x, y| y.0.cmp(&x.0)); + let mut out: Vec = scored.iter().filter(|(s, _)| *s > 0).map(|(_, a)| (*a).clone()).collect(); + if out.is_empty() { + out = ranked.to_vec(); + } + out.into_iter().take(cap).collect() +} + async fn validate(candidates: Vec, pool: &ModelPool, sys: &str, vote_n: usize, tx: &Sender) -> Vec { + // Prefer a model other than the primary (likely finder) to adjudicate. + let finder = pool.candidates.first().map(|m| m.label()); let validated: Vec = stream::iter(candidates.into_iter()) .map(|mut f| { let txc = tx.clone(); + let finder = finder.clone(); async move { let q = format!( "Finding: {} | severity {} | {} | at {} | payload {} | evidence {}", f.title, f.severity, f.cwe, f.endpoint, f.payload, f.evidence ); - let (yes, total) = pool.vote(sys, &q, vote_n).await; + let (yes, total) = pool.vote(sys, &q, vote_n, finder.as_deref()).await; f.validated = total > 0 && yes * 2 >= total; f.votes = format!("{yes}/{total}"); if f.confidence == 0.0 && total > 0 { @@ -268,6 +384,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin } RunOutput { + target: cfg.target.clone(), candidates: findings.len(), findings, agents_ran: selected.iter().map(|a| a.name.clone()).collect(), diff --git a/neurosploit-rs/crates/harness/src/pool.rs b/neurosploit-rs/crates/harness/src/pool.rs index 8145be8..cd087de 100644 --- a/neurosploit-rs/crates/harness/src/pool.rs +++ b/neurosploit-rs/crates/harness/src/pool.rs @@ -87,8 +87,18 @@ impl ModelPool { /// Ask up to `n` distinct models the same yes/no validation question and /// return (confirmations, total_votes). A model answering "yes"/"confirmed" /// counts as a confirmation. Used to cut false positives. - pub async fn vote(&self, system: &str, user: &str, n: usize) -> (usize, usize) { - let panel: Vec = self.candidates.iter().take(n.max(1)).cloned().collect(); + /// + /// `skip` names the model that produced the finding; when the panel has more + /// than one model, that model is moved to the back so a DIFFERENT model + /// adjudicates first (cross-model false-positive validation). + pub async fn vote(&self, system: &str, user: &str, n: usize, skip: Option<&str>) -> (usize, usize) { + let mut ordered: Vec = self.candidates.clone(); + if let Some(finder) = skip { + if ordered.len() > 1 { + ordered.sort_by_key(|m| m.label() == finder); // finder (true) sorts last + } + } + let panel: Vec = ordered.into_iter().take(n.max(1)).collect(); let mut confirmed = 0usize; let mut total = 0usize; for m in &panel { diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index 111d9bd..e7a723c 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -1,4 +1,9 @@ use crate::types::Finding; +use std::path::{Path, PathBuf}; + +/// The blank, structured Typst template (rendering logic). Data (`meta`, +/// `findings`) is prepended by `typst_report` to make a self-contained file. +const TYPST_TEMPLATE: &str = include_str!("../../../templates/report.typ"); fn sev_rank(s: &str) -> u8 { match s { @@ -74,9 +79,70 @@ pub fn html(target: &str, findings: &[Finding]) -> String { h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\ .b{{color:#8b5cf6;font-weight:800}}\

NeuroSploit Penetration Test Report

\ -
Target: {t} · v3.4.0 Rust harness · multi-model validated
\ +
Target: {t} · v3.4.1 Rust harness · multi-model validated
\
{chips}

Findings ({n})

{body}\

Authorized testing only. Findings confirmed by multi-model adversarial voting.

", t = esc(target), chips = chips, n = sorted.len(), body = body, ) } + +// ===== Typst report ===== + +/// Is the `typst` binary available on PATH? +fn typst_available() -> bool { + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).any(|d| d.join("typst").is_file())) + .unwrap_or(false) +} + +fn sorted_findings(findings: &[Finding]) -> Vec { + let mut v = findings.to_vec(); + v.sort_by_key(|f| sev_rank(&f.severity)); + v +} + +/// Escape a string for embedding inside a Typst `"..."` literal (single line). +fn tq(s: &str) -> String { + let cleaned: String = s.replace('\\', "\\\\").replace('"', "\\\"").replace(['\n', '\r'], " "); + format!("\"{}\"", cleaned) +} + +/// Generate a self-contained `report.typ` (data + bundled template) in `dir` +/// and compile it to `report.pdf` via the `typst` binary. Falls back to leaving +/// the `.typ` when `typst` is unavailable. +pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string(); + + let mut data = String::new(); + data.push_str(&format!( + "#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n", + tq(target), tq(&run_id), tq("NeuroSploit v3.4.1"), tq("multi-model") + )); + data.push_str("#let findings = (\n"); + for f in sorted_findings(findings) { + data.push_str(&format!( + " (severity: {}, title: {}, agent: {}, cwe: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}),\n", + tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&f.cvss), + tq(&f.endpoint), tq(&f.payload), tq(&f.evidence), tq(&f.impact), + tq(&f.remediation), tq(&f.votes), f.confidence, + )); + } + data.push_str(")\n\n"); + + let typ_path = dir.join("report.typ"); + std::fs::write(&typ_path, format!("{data}{TYPST_TEMPLATE}"))?; + + if typst_available() { + let pdf_path = dir.join("report.pdf"); + match std::process::Command::new("typst") + .arg("compile").arg(&typ_path).arg(&pdf_path).output() + { + Ok(o) if o.status.success() && pdf_path.exists() => return Ok(pdf_path), + Ok(o) => eprintln!("typst compile failed: {}", + String::from_utf8_lossy(&o.stderr).lines().next().unwrap_or("").trim()), + Err(e) => eprintln!("typst not runnable: {e}"), + } + } + Ok(typ_path) +} diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index 66d0594..d9cc0ad 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -80,6 +80,9 @@ pub struct RunConfig { /// Path to the RL reward state file. #[serde(default)] pub rl_path: Option, + /// Verbose: log each agent as it launches, recon snippet, and votes. + #[serde(default)] + pub verbose: bool, } fn default_vote() -> usize { @@ -101,6 +104,7 @@ impl RunConfig { subscription: false, workdir: None, rl_path: None, + verbose: false, } } } diff --git a/neurosploit-rs/templates/report.typ b/neurosploit-rs/templates/report.typ new file mode 100644 index 0000000..14ad44f --- /dev/null +++ b/neurosploit-rs/templates/report.typ @@ -0,0 +1,97 @@ +// NeuroSploit v3.4.1 — Typst report template (blank, structured). +// +// The harness generates `report.typ` per run by prepending a `findings` array +// and a `meta` dict, then including this template's rendering logic. This file +// is the reference/blank template: it renders a cover, an executive summary with +// severity counts, and one section per finding. Compile with: +// typst compile report.typ report.pdf +// +// Expected inputs (defined above this template in the generated file): +// #let meta = (target: "", run_id: "", generated: "", model: "") +// #let findings = ( (severity: "", title: "", agent: "", cwe: "", cvss: "", +// endpoint: "", payload: "", evidence: "", impact: "", +// remediation: "", votes: "", confidence: 0.0), ... ) + +#let sevcolor = ( + Critical: rgb("#c0392b"), High: rgb("#e67e22"), Medium: rgb("#f1c40f"), + Low: rgb("#3498db"), Info: rgb("#7f8c8d"), +) +#let sevbadge(s) = box( + fill: sevcolor.at(s, default: rgb("#7f8c8d")), inset: (x: 5pt, y: 2pt), + radius: 3pt, text(fill: white, weight: "bold", size: 8pt, upper(s)), +) +#let sevrank(s) = (Critical: 0, High: 1, Medium: 2, Low: 3, Info: 4).at(s, default: 5) + +#set page(margin: 2cm, numbering: "1", footer: context [ + #set text(size: 8pt, fill: gray) + NeuroSploit v3.4.1 · #meta.target · confidential + #h(1fr) #counter(page).display() +]) +#set text(font: ("Helvetica Neue", "Helvetica", "Arial"), size: 10pt) +#set heading(numbering: none) + +// ---- Cover ---- +#v(3cm) +#align(center)[ + #text(28pt, weight: "bold")[#text(fill: rgb("#7c5cff"))[Neuro]Sploit] + #v(2pt) + #text(15pt, fill: gray)[Penetration Test Report] + #v(1cm) + #text(13pt)[Target: #strong(meta.target)] + #v(4pt) + #text(10pt, fill: gray)[Run #meta.run_id · #meta.generated · models: #meta.model] +] +#pagebreak() + +// ---- Executive summary ---- += Executive Summary + +#let counts = (:) +#for f in findings { + counts.insert(f.severity, counts.at(f.severity, default: 0) + 1) +} +#if findings.len() == 0 [ + No validated findings were produced for this engagement. All candidate issues + were either unproven or rejected by multi-model adversarial validation. +] else [ + This engagement produced #strong(str(findings.len())) validated finding(s), + each confirmed by multi-model voting. + + #v(6pt) + #grid(columns: 5, gutter: 8pt, + ..("Critical", "High", "Medium", "Low", "Info").map(s => box( + width: 100%, inset: 8pt, radius: 6pt, stroke: 0.5pt + sevcolor.at(s), + align(center)[ + #text(18pt, weight: "bold", fill: sevcolor.at(s))[#str(counts.at(s, default: 0))] + #v(-4pt) #text(8pt, upper(s)) + ], + )) + ) +] + +#v(10pt) +#line(length: 100%, stroke: 0.5pt + gray) + +// ---- Findings ---- += Findings + +#let sorted = findings.sorted(key: f => sevrank(f.severity)) +#if sorted.len() == 0 [ + #text(fill: gray)[_Nothing to report._] +] +#for (i, f) in sorted.enumerate() [ + #block(breakable: false, width: 100%, inset: 10pt, radius: 6pt, + stroke: (left: 3pt + sevcolor.at(f.severity, default: gray), rest: 0.5pt + rgb("#dddddd")))[ + #sevbadge(f.severity) #h(6pt) #text(12pt, weight: "bold")[#str(i + 1). #f.title] + #v(4pt) + #text(9pt, fill: gray)[ + agent: #raw(f.agent) · CWE: #f.cwe · CVSS: #f.cvss · votes: #f.votes · confidence: #str(f.confidence) + ] + #v(2pt) #text(9pt)[Endpoint: #raw(f.endpoint)] + #v(5pt) #strong[Payload] #linebreak() #raw(f.payload) + #v(3pt) #strong[Evidence] #linebreak() #raw(f.evidence) + #v(3pt) #strong[Impact:] #f.impact + #v(2pt) #strong[Remediation:] #f.remediation + ] + #v(8pt) +]