mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-15 14:10:22 +02:00
v3.5.0: Claude-Code-style interactive harness (REPL) + instruction-steered testing
- New persistent interactive session (app/src/repl.rs), launched when run with no args:
banner, model selection, API-key config (/key) or subscription (/sub), then a live
session to set /target, /repo, /auth, and free-text /focus instructions (or just type
them) that STEER which agents run and how.
- Slash-commands: /model /providers /key /sub /target /repo /auth /focus /mcp /votes
/agents /show /run /quit (+ bare text = focus).
- RunConfig gains `instructions` and `auth`:
* instructions bias both LLM agent-selection and the heuristic (focus keywords →
injection/access-control/etc. agents get a strong boost)
* operator directives (focus + auth) injected into recon and exploit prompts so agents
test as an authenticated user and prioritise the requested vuln classes
- bump 3.4.1 → 3.5.0 (CLI, harness, reports, credits)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5d83e8848e
commit
435463979b
Generated
+2
-2
@@ -604,7 +604,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neurosploit"
|
name = "neurosploit"
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -617,7 +617,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "neurosploit-harness"
|
name = "neurosploit-harness"
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.4.1"
|
version = "3.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/JoasASantos/NeuroSploit"
|
repository = "https://github.com/JoasASantos/NeuroSploit"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! NeuroSploit v3.4.1 — CLI: `run` (black-box) / `whitebox` (source) / `agents` / `models`.
|
//! NeuroSploit v3.5.0 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
|
||||||
|
|
||||||
|
mod repl;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use harness::{agents, models::ModelRef, pool::ModelPool, types::RunConfig, RunOutput};
|
use harness::{agents, models::ModelRef, pool::ModelPool, types::RunConfig, RunOutput};
|
||||||
@@ -8,8 +10,8 @@ use std::path::{Path, PathBuf};
|
|||||||
#[command(
|
#[command(
|
||||||
name = "neurosploit",
|
name = "neurosploit",
|
||||||
version,
|
version,
|
||||||
about = "NeuroSploit v3.4.1 — multi-model autonomous pentest harness",
|
about = "NeuroSploit v3.5.0 — multi-model autonomous pentest harness",
|
||||||
long_about = "NeuroSploit v3.4.1 — a Rust multi-model harness that drives a pool of LLMs \
|
long_about = "NeuroSploit v3.5.0 — 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. \
|
(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 \
|
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\
|
them in parallel, then validates every finding by cross-model voting before reporting.\n\n\
|
||||||
@@ -107,9 +109,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let base = find_base();
|
let base = find_base();
|
||||||
|
|
||||||
|
// No subcommand → launch the Claude-Code-style interactive session.
|
||||||
let cmd = match cli.cmd {
|
let cmd = match cli.cmd {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => interactive(&base).await?, // no args → wizard
|
None => {
|
||||||
|
repl::repl(&base).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
@@ -159,8 +165,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared engagement runner for `run` / `whitebox`.
|
/// Shared engagement runner for `run` / `whitebox` / the interactive session.
|
||||||
async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bool) -> anyhow::Result<RunOutput> {
|
pub(crate) async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bool) -> anyhow::Result<RunOutput> {
|
||||||
let lib = agents::load(base);
|
let lib = agents::load(base);
|
||||||
|
|
||||||
// Unique, sortable run id → runs/<id>/
|
// Unique, sortable run id → runs/<id>/
|
||||||
@@ -171,7 +177,7 @@ async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bo
|
|||||||
cfg.rl_path = Some(base.join("data").join("rl_state_rs.json").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));
|
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
|
||||||
|
|
||||||
println!(" ┌─ NeuroSploit v3.4.1 · by Joas A Santos & Red Team Leaders");
|
println!(" ┌─ NeuroSploit v3.5.0 · by Joas A Santos & Red Team Leaders");
|
||||||
println!(" │ run id : {run_id}");
|
println!(" │ run id : {run_id}");
|
||||||
println!(" │ target : {}", cfg.target);
|
println!(" │ target : {}", cfg.target);
|
||||||
println!(" │ models : {}", cfg.models.join(", "));
|
println!(" │ models : {}", cfg.models.join(", "));
|
||||||
@@ -235,7 +241,7 @@ async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bo
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_findings(out: &RunOutput) {
|
pub(crate) fn print_findings(out: &RunOutput) {
|
||||||
println!("\n=== {} validated finding(s) ===", out.findings.len());
|
println!("\n=== {} validated finding(s) ===", out.findings.len());
|
||||||
println!("{}", serde_json::to_string_pretty(&out.findings).unwrap_or_default());
|
println!("{}", serde_json::to_string_pretty(&out.findings).unwrap_or_default());
|
||||||
if !out.artifacts.is_empty() {
|
if !out.artifacts.is_empty() {
|
||||||
@@ -262,46 +268,3 @@ fn write_status(workdir: &Path, state: &str, extra: &str) {
|
|||||||
if extra.is_empty() { String::new() } else { format!(",{extra}") }));
|
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!(" │ by Joas A Santos & Red Team Leaders │");
|
|
||||||
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 }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
//! NeuroSploit v3.5.0 — interactive session (Claude-Code / Codex / Cursor-CLI style).
|
||||||
|
//!
|
||||||
|
//! Launched when `neurosploit` runs with no subcommand. A persistent REPL where
|
||||||
|
//! you pick models, set an API key (or use a subscription login), point at a URL
|
||||||
|
//! or a repo, configure authentication, and write free-text instructions that
|
||||||
|
//! steer which agents run and how — e.g. "find injection and broken access
|
||||||
|
//! control". `/run` then executes the engagement with that configuration.
|
||||||
|
|
||||||
|
use harness::{agents, types::RunConfig};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Mutable session state edited via slash-commands and consumed by `/run`.
|
||||||
|
struct Session {
|
||||||
|
models: Vec<String>,
|
||||||
|
subscription: bool,
|
||||||
|
mcp: bool,
|
||||||
|
vote_n: usize,
|
||||||
|
max_agents: usize,
|
||||||
|
target: Option<String>,
|
||||||
|
repo: Option<String>,
|
||||||
|
auth: Option<String>,
|
||||||
|
instructions: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Session {
|
||||||
|
fn default() -> Self {
|
||||||
|
Session {
|
||||||
|
models: vec!["anthropic:claude-opus-4-8".into()],
|
||||||
|
subscription: harness::installed_cli_backends().contains(&"claude"),
|
||||||
|
mcp: false,
|
||||||
|
vote_n: 3,
|
||||||
|
max_agents: 0,
|
||||||
|
target: None,
|
||||||
|
repo: None,
|
||||||
|
auth: None,
|
||||||
|
instructions: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT: &str = "\x1b[35mneurosploit›\x1b[0m ";
|
||||||
|
|
||||||
|
pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||||
|
let lib = agents::load(base);
|
||||||
|
let backends = harness::installed_cli_backends();
|
||||||
|
println!("\x1b[1m");
|
||||||
|
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
|
||||||
|
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.5.0");
|
||||||
|
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
|
||||||
|
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
|
||||||
|
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
|
||||||
|
println!(" ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝\x1b[0m");
|
||||||
|
println!(" {} agents loaded · detected logins: {}", lib.total(),
|
||||||
|
if backends.is_empty() { "none (use API keys)".into() } else { backends.join(", ") });
|
||||||
|
println!(" Type \x1b[36m/help\x1b[0m to get started, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit.\n");
|
||||||
|
|
||||||
|
let mut s = Session::default();
|
||||||
|
show(&s);
|
||||||
|
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
loop {
|
||||||
|
print!("{PROMPT}");
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
let mut line = String::new();
|
||||||
|
if stdin.read_line(&mut line).unwrap_or(0) == 0 {
|
||||||
|
println!();
|
||||||
|
break; // EOF (Ctrl-D)
|
||||||
|
}
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A bare line that isn't a command is treated as test instructions.
|
||||||
|
if !line.starts_with('/') {
|
||||||
|
s.instructions = Some(line.to_string());
|
||||||
|
println!(" focus set: {line}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut parts = line.splitn(2, char::is_whitespace);
|
||||||
|
let cmd = parts.next().unwrap_or("");
|
||||||
|
let arg = parts.next().unwrap_or("").trim();
|
||||||
|
match cmd {
|
||||||
|
"/help" | "/?" => help(),
|
||||||
|
"/show" | "/config" => show(&s),
|
||||||
|
"/providers" => {
|
||||||
|
for p in harness::providers() {
|
||||||
|
println!(" [{}] {:<14} {}", p.kind, p.key,
|
||||||
|
p.models.iter().map(|m| format!("{}:{}", p.key, m)).collect::<Vec<_>>().join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/model" | "/models" => {
|
||||||
|
if arg.is_empty() {
|
||||||
|
println!(" current: {}", s.models.join(", "));
|
||||||
|
} else {
|
||||||
|
s.models = arg.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect();
|
||||||
|
println!(" models: {}", s.models.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/key" => {
|
||||||
|
// /key <PROVIDER> <KEY> → sets the provider's env var for this session
|
||||||
|
let mut kp = arg.splitn(2, char::is_whitespace);
|
||||||
|
match (kp.next(), kp.next()) {
|
||||||
|
(Some(prov), Some(key)) if !key.trim().is_empty() => {
|
||||||
|
match harness::provider_for(prov) {
|
||||||
|
Some(p) => {
|
||||||
|
std::env::set_var(p.env_key, key.trim());
|
||||||
|
s.subscription = false;
|
||||||
|
println!(" set {} (API mode)", p.env_key);
|
||||||
|
}
|
||||||
|
None => println!(" unknown provider '{prov}' (see /providers)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => println!(" usage: /key <provider> <api-key> e.g. /key anthropic sk-ant-..."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/sub" | "/subscription" => {
|
||||||
|
s.subscription = !matches!(arg, "off" | "false" | "0" | "no");
|
||||||
|
println!(" subscription: {}", onoff(s.subscription));
|
||||||
|
}
|
||||||
|
"/target" | "/url" => {
|
||||||
|
let t = if arg.starts_with("http") || arg.is_empty() { arg.to_string() } else { format!("https://{arg}") };
|
||||||
|
s.target = if t.is_empty() { None } else { Some(t) };
|
||||||
|
s.repo = None;
|
||||||
|
println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
}
|
||||||
|
"/repo" => {
|
||||||
|
s.repo = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
|
s.target = None;
|
||||||
|
println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
}
|
||||||
|
"/auth" => {
|
||||||
|
s.auth = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
|
println!(" auth: {}", s.auth.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
}
|
||||||
|
"/focus" | "/instructions" => {
|
||||||
|
s.instructions = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
|
println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
}
|
||||||
|
"/mcp" => {
|
||||||
|
s.mcp = !matches!(arg, "off" | "false" | "0" | "no");
|
||||||
|
println!(" Playwright MCP: {}", onoff(s.mcp));
|
||||||
|
}
|
||||||
|
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
|
||||||
|
"/agents" => { s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {} ", s.max_agents); }
|
||||||
|
"/clear" => { print!("\x1b[2J\x1b[H"); }
|
||||||
|
"/run" | "/go" => run(base, &s).await,
|
||||||
|
"/quit" | "/exit" | "/q" => { println!(" bye."); break; }
|
||||||
|
other => println!(" unknown command '{other}' — try /help"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(base: &Path, s: &Session) {
|
||||||
|
let (target, whitebox) = match (&s.repo, &s.target) {
|
||||||
|
(Some(r), _) => (r.clone(), true),
|
||||||
|
(_, Some(t)) => (t.clone(), false),
|
||||||
|
_ => {
|
||||||
|
println!(" \x1b[31m✗ set a /target <url> or /repo <path> first.\x1b[0m");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut cfg = RunConfig::new(&target);
|
||||||
|
cfg.models = s.models.clone();
|
||||||
|
cfg.subscription = s.subscription;
|
||||||
|
cfg.vote_n = s.vote_n;
|
||||||
|
cfg.max_agents = s.max_agents;
|
||||||
|
cfg.verbose = true;
|
||||||
|
cfg.instructions = s.instructions.clone();
|
||||||
|
cfg.auth = s.auth.clone();
|
||||||
|
|
||||||
|
match crate::run_engagement(base, cfg, s.mcp && !whitebox, whitebox).await {
|
||||||
|
Ok(out) => crate::print_findings(&out),
|
||||||
|
Err(e) => println!(" \x1b[31m✗ run failed: {e}\x1b[0m"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(s: &Session) {
|
||||||
|
println!(" ┌─ session");
|
||||||
|
println!(" │ models : {}", s.models.join(", "));
|
||||||
|
println!(" │ auth mode: {}", if s.subscription { "subscription (CLI login)" } else { "API key" });
|
||||||
|
println!(" │ target : {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
println!(" │ repo : {}", s.repo.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
println!(" │ auth : {}", s.auth.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
println!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into()));
|
||||||
|
println!(" │ mcp : {} votes: {} max-agents: {}", onoff(s.mcp), s.vote_n, s.max_agents);
|
||||||
|
println!(" └─ /run to launch");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help() {
|
||||||
|
println!(" Commands:");
|
||||||
|
println!(" /model a:b[,c:d] set model panel (1st primary; rest fail over + vote)");
|
||||||
|
println!(" /providers list providers & models");
|
||||||
|
println!(" /key <prov> <key> set a provider API key (switches to API mode)");
|
||||||
|
println!(" /sub on|off use local subscription login instead of API key");
|
||||||
|
println!(" /target <url> black-box target URL");
|
||||||
|
println!(" /repo <path> white-box: analyse a local repository");
|
||||||
|
println!(" /auth <value> auth to send (e.g. 'Authorization: Bearer <jwt>' or 'Cookie: s=..')");
|
||||||
|
println!(" /focus <text> steer the tests, e.g. 'injection and broken access control'");
|
||||||
|
println!(" (or just type the instruction with no slash)");
|
||||||
|
println!(" /mcp on|off enable Playwright MCP browser (subscription path)");
|
||||||
|
println!(" /votes <n> validator votes per finding");
|
||||||
|
println!(" /agents <n> cap agents (0 = all matching)");
|
||||||
|
println!(" /show show current session config");
|
||||||
|
println!(" /run launch the engagement");
|
||||||
|
println!(" /quit exit");
|
||||||
|
println!();
|
||||||
|
println!(" Example:");
|
||||||
|
println!(" /model anthropic:claude-opus-4-8");
|
||||||
|
println!(" /target http://testphp.vulnweb.com/");
|
||||||
|
println!(" find injection and broken access control");
|
||||||
|
println!(" /run");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn onoff(b: bool) -> &'static str {
|
||||||
|
if b { "on" } else { "off" }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! NeuroSploit v3.4.1 harness — a robust multi-model runtime for the
|
//! NeuroSploit v3.5.0 harness — a robust multi-model runtime for the
|
||||||
//! markdown-driven autonomous pentest engine.
|
//! markdown-driven autonomous pentest engine.
|
||||||
//!
|
//!
|
||||||
//! The harness loads the `agents_md/` library, drives a *pool* of LLM models
|
//! The harness loads the `agents_md/` library, drives a *pool* of LLM models
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ pub struct RunOutput {
|
|||||||
|
|
||||||
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.";
|
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.";
|
||||||
|
|
||||||
|
/// Operator directives (focus instructions + auth material) prepended to
|
||||||
|
/// recon/exploit prompts so the engagement is steered as the user asked.
|
||||||
|
fn operator_directives(cfg: &RunConfig) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
if let Some(focus) = cfg.instructions.as_deref().filter(|x| !x.trim().is_empty()) {
|
||||||
|
s.push_str(&format!("OPERATOR FOCUS — prioritise this: {focus}\n"));
|
||||||
|
}
|
||||||
|
if let Some(auth) = cfg.auth.as_deref().filter(|x| !x.trim().is_empty()) {
|
||||||
|
s.push_str(&format!("AUTHENTICATION — test as an authenticated user; send this with each request: {auth}\n"));
|
||||||
|
}
|
||||||
|
if !s.is_empty() {
|
||||||
|
s.push('\n');
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool-usage doctrine prepended to recon/exploit prompts so the agent knows
|
/// 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),
|
/// exactly what it may use. Best run on Kali Linux (or the Kali Docker image),
|
||||||
/// where these tools are preinstalled.
|
/// where these tools are preinstalled.
|
||||||
@@ -68,7 +84,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
|||||||
let _ = tx.send("recon: offline mode — skipping model calls".into()).await;
|
let _ = tx.send("recon: offline mode — skipping model calls".into()).await;
|
||||||
"{}".to_string()
|
"{}".to_string()
|
||||||
} else {
|
} else {
|
||||||
let recon_user = format!("{}Target: {}", tool_doctrine(pool.mcp_config.is_some()), cfg.target);
|
let recon_user = format!("{}{}Target: {}", operator_directives(&cfg), tool_doctrine(pool.mcp_config.is_some()), cfg.target);
|
||||||
match pool.complete_routed(Task::Recon, RECON_SYS, &recon_user).await {
|
match pool.complete_routed(Task::Recon, RECON_SYS, &recon_user).await {
|
||||||
Ok((m, t)) => {
|
Ok((m, t)) => {
|
||||||
let _ = tx.send(format!("recon complete via {}", m.label())).await;
|
let _ = tx.send(format!("recon complete via {}", m.label())).await;
|
||||||
@@ -101,19 +117,20 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
|||||||
|
|
||||||
// Use the model to pick the agents whose preconditions match the recon —
|
// Use the model to pick the agents whose preconditions match the recon —
|
||||||
// the harness reasons about *which* specialists to run, not all of them.
|
// the harness reasons about *which* specialists to run, not all of them.
|
||||||
let chosen = select_agents(pool, &recon, &ranked, &tx).await;
|
let focus = cfg.instructions.clone().unwrap_or_default();
|
||||||
|
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
|
||||||
let selected: Vec<Agent> = if !chosen.is_empty() {
|
let selected: Vec<Agent> = if !chosen.is_empty() {
|
||||||
let sel: Vec<Agent> =
|
let sel: Vec<Agent> =
|
||||||
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect();
|
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect();
|
||||||
if sel.is_empty() {
|
if sel.is_empty() {
|
||||||
heuristic_select(&ranked, &recon, cap)
|
heuristic_select(&ranked, &recon, &focus, cap)
|
||||||
} else {
|
} else {
|
||||||
sel.into_iter().take(cap).collect()
|
sel.into_iter().take(cap).collect()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// LLM selection failed/empty → recon-keyword heuristic, not a blind flat list.
|
// LLM selection failed/empty → recon+focus keyword heuristic, not a blind flat list.
|
||||||
let _ = tx.send("selection empty — using recon-keyword heuristic".into()).await;
|
let _ = tx.send("selection empty — using recon-keyword heuristic".into()).await;
|
||||||
heuristic_select(&ranked, &recon, cap)
|
heuristic_select(&ranked, &recon, &focus, cap)
|
||||||
};
|
};
|
||||||
// Dedup: never run the same agent twice in one engagement.
|
// Dedup: never run the same agent twice in one engagement.
|
||||||
let selected: Vec<Agent> = {
|
let selected: Vec<Agent> = {
|
||||||
@@ -129,12 +146,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
|||||||
let target = cfg.target.clone();
|
let target = cfg.target.clone();
|
||||||
let verbose = cfg.verbose;
|
let verbose = cfg.verbose;
|
||||||
let mcp_on = pool.mcp_config.is_some();
|
let mcp_on = pool.mcp_config.is_some();
|
||||||
|
let directives = operator_directives(&cfg);
|
||||||
// Token economy: each agent gets a capped recon context, not the full blob.
|
// Token economy: each agent gets a capped recon context, not the full blob.
|
||||||
let recon_ctx: String = recon.chars().take(3500).collect();
|
let recon_ctx: String = recon.chars().take(3500).collect();
|
||||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||||
.map(|ag| {
|
.map(|ag| {
|
||||||
let target = target.clone();
|
let target = target.clone();
|
||||||
let recon = recon_ctx.clone();
|
let recon = recon_ctx.clone();
|
||||||
|
let directives = directives.clone();
|
||||||
let txc = tx.clone();
|
let txc = tx.clone();
|
||||||
async move {
|
async move {
|
||||||
if verbose {
|
if verbose {
|
||||||
@@ -143,10 +162,11 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
|||||||
let user = format!(
|
let user = format!(
|
||||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||||
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
||||||
{react}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
{directives}{react}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}. \
|
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}. \
|
||||||
`evidence` must contain the concrete proof (request/response excerpt).",
|
`evidence` must contain the concrete proof (request/response excerpt).",
|
||||||
target = target,
|
target = target,
|
||||||
|
directives = directives,
|
||||||
react = REACT_DOCTRINE,
|
react = REACT_DOCTRINE,
|
||||||
doctrine = tool_doctrine(mcp_on),
|
doctrine = tool_doctrine(mcp_on),
|
||||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||||
@@ -243,7 +263,7 @@ const SELECT_SYS: &str = "You are a penetration-test orchestrator. Given recon o
|
|||||||
|
|
||||||
/// Ask the model which agents to run for this recon. Returns chosen agent names
|
/// Ask the model which agents to run for this recon. Returns chosen agent names
|
||||||
/// (empty on failure → caller falls back to RL-ranked agents).
|
/// (empty on failure → caller falls back to RL-ranked agents).
|
||||||
async fn select_agents(pool: &ModelPool, recon: &str, catalog: &[Agent], tx: &Sender<String>) -> Vec<String> {
|
async fn select_agents(pool: &ModelPool, recon: &str, focus: &str, catalog: &[Agent], tx: &Sender<String>) -> Vec<String> {
|
||||||
let list = catalog
|
let list = catalog
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| format!("{} — {} [{}]", a.name, a.title.replace(" Agent", ""), a.cwe))
|
.map(|a| format!("{} — {} [{}]", a.name, a.title.replace(" Agent", ""), a.cwe))
|
||||||
@@ -251,7 +271,12 @@ async fn select_agents(pool: &ModelPool, recon: &str, catalog: &[Agent], tx: &Se
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
// Token economy: cap the recon blob fed to the selector.
|
// Token economy: cap the recon blob fed to the selector.
|
||||||
let recon_trim: String = recon.chars().take(3000).collect();
|
let recon_trim: String = recon.chars().take(3000).collect();
|
||||||
let user = format!("RECON:\n{recon_trim}\n\nAGENT CATALOG (name — title [cwe]):\n{list}\n\nReturn a JSON array of agent names to run.");
|
let focus_line = if focus.trim().is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("OPERATOR FOCUS (strongly prioritise agents for this): {focus}\n\n")
|
||||||
|
};
|
||||||
|
let user = format!("{focus_line}RECON:\n{recon_trim}\n\nAGENT CATALOG (name — title [cwe]):\n{list}\n\nReturn a JSON array of agent names to run.");
|
||||||
match pool.complete_routed(Task::Select, SELECT_SYS, &user).await {
|
match pool.complete_routed(Task::Select, SELECT_SYS, &user).await {
|
||||||
Ok((m, text)) => {
|
Ok((m, text)) => {
|
||||||
let names = parse_string_array(&text);
|
let names = parse_string_array(&text);
|
||||||
@@ -280,7 +305,7 @@ fn parse_string_array(text: &str) -> Vec<String> {
|
|||||||
/// Fallback agent selection when the LLM selector fails: score each agent by
|
/// 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
|
/// 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`.
|
/// black-box baseline of high-yield web classes, and take the top `cap`.
|
||||||
fn heuristic_select(ranked: &[Agent], recon: &str, cap: usize) -> Vec<Agent> {
|
fn heuristic_select(ranked: &[Agent], recon: &str, focus: &str, cap: usize) -> Vec<Agent> {
|
||||||
const BASELINE: &[&str] = &[
|
const BASELINE: &[&str] = &[
|
||||||
"sqli_error", "sqli_blind", "sqli_union", "xss_reflected", "xss_stored", "xss_dom",
|
"sqli_error", "sqli_blind", "sqli_union", "xss_reflected", "xss_stored", "xss_dom",
|
||||||
"command_injection", "lfi", "path_traversal", "ssrf", "idor", "open_redirect",
|
"command_injection", "lfi", "path_traversal", "ssrf", "idor", "open_redirect",
|
||||||
@@ -288,6 +313,7 @@ fn heuristic_select(ranked: &[Agent], recon: &str, cap: usize) -> Vec<Agent> {
|
|||||||
"security_headers", "cors_misconfig",
|
"security_headers", "cors_misconfig",
|
||||||
];
|
];
|
||||||
let r = recon.to_lowercase();
|
let r = recon.to_lowercase();
|
||||||
|
let f = focus.to_lowercase();
|
||||||
// Recon signal → agent-name substrings. Only agents whose surface the recon
|
// Recon signal → agent-name substrings. Only agents whose surface the recon
|
||||||
// actually identified get the signal boost; the rest rely on the baseline.
|
// actually identified get the signal boost; the rest rely on the baseline.
|
||||||
let signals: &[(&str, &[&str])] = &[
|
let signals: &[(&str, &[&str])] = &[
|
||||||
@@ -335,6 +361,18 @@ fn heuristic_select(ranked: &[Agent], recon: &str, cap: usize) -> Vec<Agent> {
|
|||||||
score += 2;
|
score += 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// operator focus: strongly boost agents matching the requested classes
|
||||||
|
if !f.is_empty() {
|
||||||
|
let blob = format!("{} {}", a.name, a.title).to_lowercase();
|
||||||
|
let hit = ["inject", "sqli", "xss", "ssrf", "ssti", "rce", "command", "lfi", "rfi",
|
||||||
|
"idor", "bola", "bfla", "access", "auth", "privilege", "csrf", "redirect",
|
||||||
|
"deserial", "xxe", "traversal", "upload", "jwt", "secret", "crypto"]
|
||||||
|
.iter()
|
||||||
|
.any(|kw| f.contains(kw) && blob.contains(kw));
|
||||||
|
if hit {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
(score, a)
|
(score, a)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
|
|||||||
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
|
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
|
||||||
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
|
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
|
||||||
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
|
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
|
||||||
<div class=meta>Target: <b>{t}</b> · v3.4.1 Rust harness · multi-model validated</div>\
|
<div class=meta>Target: <b>{t}</b> · v3.5.0 Rust harness · multi-model validated</div>\
|
||||||
<div>{chips}</div><h2>Findings ({n})</h2>{body}\
|
<div>{chips}</div><h2>Findings ({n})</h2>{body}\
|
||||||
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.4.1 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.<br>NeuroSploit v3.5.0 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
||||||
t = esc(target), chips = chips, n = sorted.len(), body = body,
|
t = esc(target), chips = chips, n = sorted.len(), body = body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
|
|||||||
let mut data = String::new();
|
let mut data = String::new();
|
||||||
data.push_str(&format!(
|
data.push_str(&format!(
|
||||||
"#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n",
|
"#let meta = (target: {}, run_id: {}, generated: {}, model: {})\n",
|
||||||
tq(target), tq(&run_id), tq("NeuroSploit v3.4.1"), tq("multi-model")
|
tq(target), tq(&run_id), tq("NeuroSploit v3.5.0"), tq("multi-model")
|
||||||
));
|
));
|
||||||
data.push_str("#let findings = (\n");
|
data.push_str("#let findings = (\n");
|
||||||
for f in sorted_findings(findings) {
|
for f in sorted_findings(findings) {
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ pub struct RunConfig {
|
|||||||
/// Verbose: log each agent as it launches, recon snippet, and votes.
|
/// Verbose: log each agent as it launches, recon snippet, and votes.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
/// Free-text instructions from the operator that steer agent selection and
|
||||||
|
/// execution (e.g. "focus on injection and broken access control").
|
||||||
|
#[serde(default)]
|
||||||
|
pub instructions: Option<String>,
|
||||||
|
/// Authentication material to use against the target so agents test as an
|
||||||
|
/// authenticated user (e.g. "Authorization: Bearer <jwt>" or "Cookie: session=...").
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_vote() -> usize {
|
fn default_vote() -> usize {
|
||||||
@@ -105,6 +113,8 @@ impl RunConfig {
|
|||||||
workdir: None,
|
workdir: None,
|
||||||
rl_path: None,
|
rl_path: None,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
|
instructions: None,
|
||||||
|
auth: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// NeuroSploit v3.4.1 — Typst report template (blank, structured).
|
// NeuroSploit v3.5.0 — Typst report template (blank, structured).
|
||||||
//
|
//
|
||||||
// The harness generates `report.typ` per run by prepending a `findings` array
|
// 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
|
// and a `meta` dict, then including this template's rendering logic. This file
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
#set page(margin: 2cm, numbering: "1", footer: context [
|
#set page(margin: 2cm, numbering: "1", footer: context [
|
||||||
#set text(size: 8pt, fill: gray)
|
#set text(size: 8pt, fill: gray)
|
||||||
NeuroSploit v3.4.1 · #meta.target · confidential
|
NeuroSploit v3.5.0 · #meta.target · confidential
|
||||||
#h(1fr) #counter(page).display()
|
#h(1fr) #counter(page).display()
|
||||||
])
|
])
|
||||||
#set text(font: ("Helvetica Neue", "Helvetica", "Arial"), size: 10pt)
|
#set text(font: ("Helvetica Neue", "Helvetica", "Arial"), size: 10pt)
|
||||||
|
|||||||
Reference in New Issue
Block a user