mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-07-06 19:37:57 +02:00
v3.4.1: harness intelligence — router, ReAct, dedup, token-trim, configurable MCP, +54 code agents, credits
- Task-based model ROUTER (recon/select prefer a fast model; exploit prefers primary; validate uses a different model than the finder) - ReAct doctrine injected into exploit prompts (Thought→Action→Observation, token-efficient) - Dedup: unique agents per run + findings deduped by CWE/endpoint/title (highest confidence kept) - Token economy: recon blob capped for selector + per-agent context - Configurable MCP: merge user mcp.servers.json into the pipeline's .mcp.json - +54 white-box/code-analysis agents (NoSQLi, LDAP/XPath, JWT-none, Java/.NET/PHP/Go/Node/Python specifics, SSTI, ReDoS, deserialization, etc.) → 303 agents total (78 code) - Credits: Joas A Santos & Red Team Leaders (CLI banner, interactive header, HTML+Typst report) - README: GitHub stars/forks badges, 60-second quick start, full API config steps, intuitive layout Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -171,7 +171,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());
|
||||
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
|
||||
|
||||
println!(" ┌─ NeuroSploit v3.4.1");
|
||||
println!(" ┌─ NeuroSploit v3.4.1 · by Joas A Santos & Red Team Leaders");
|
||||
println!(" │ run id : {run_id}");
|
||||
println!(" │ target : {}", cfg.target);
|
||||
println!(" │ models : {}", cfg.models.join(", "));
|
||||
@@ -186,13 +186,19 @@ async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bo
|
||||
let providers: Vec<String> = cfg.models.iter().map(|m| ModelRef::parse(m).provider).collect();
|
||||
if providers.iter().any(|p| harness::mcp_supported(p)) {
|
||||
match harness::ensure_playwright_mcp() {
|
||||
Ok(()) => match harness::write_mcp_config(&workdir) {
|
||||
Ok(()) => {
|
||||
// Optional user-supplied extra MCP servers merged into the pipeline.
|
||||
let extra = base.join("mcp.servers.json");
|
||||
let extra_ref = if extra.is_file() { Some(extra.as_path()) } else { None };
|
||||
match harness::write_mcp_config(&workdir, extra_ref) {
|
||||
Ok(p) => {
|
||||
if extra_ref.is_some() { println!(" [*] merged extra MCP servers from mcp.servers.json"); }
|
||||
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 {
|
||||
@@ -272,6 +278,7 @@ async fn interactive(base: &Path) -> anyhow::Result<Cmd> {
|
||||
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(", ") });
|
||||
|
||||
@@ -21,5 +21,5 @@ pub use models::{
|
||||
};
|
||||
pub use pipeline::{run_whitebox, RunOutput};
|
||||
pub use pipeline::run;
|
||||
pub use pool::ModelPool;
|
||||
pub use pool::{ModelPool, Task};
|
||||
pub use types::{Finding, RunConfig};
|
||||
|
||||
@@ -265,17 +265,31 @@ pub fn ensure_playwright_mcp() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<std::path::PathBuf> {
|
||||
/// Write an `.mcp.json` into `dir` (Playwright by default) and return its path,
|
||||
/// so the agentic CLI can drive a real browser during execution. If
|
||||
/// `extra_servers` points at a JSON file shaped like `{ "mcpServers": {...} }`
|
||||
/// (or just `{...}` of servers), those servers are MERGED in — letting users
|
||||
/// plug additional MCP tools into the pipeline to potentiate testing.
|
||||
pub fn write_mcp_config(dir: &std::path::Path, extra_servers: Option<&std::path::Path>) -> std::io::Result<std::path::PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut servers = serde_json::json!({
|
||||
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] }
|
||||
});
|
||||
if let Some(extra) = extra_servers {
|
||||
if let Ok(txt) = std::fs::read_to_string(extra) {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&txt) {
|
||||
let add = v.get("mcpServers").cloned().unwrap_or(v);
|
||||
if let (Some(dst), Some(src)) = (servers.as_object_mut(), add.as_object()) {
|
||||
for (k, val) in src {
|
||||
dst.insert(k.clone(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let cfg = serde_json::json!({ "mcpServers": servers });
|
||||
let path = dir.join(".mcp.json");
|
||||
let cfg = r#"{
|
||||
"mcpServers": {
|
||||
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] }
|
||||
}
|
||||
}"#;
|
||||
std::fs::write(&path, cfg)?;
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&cfg).unwrap_or_default())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::agents::{Agent, Library};
|
||||
use crate::pool::ModelPool;
|
||||
use crate::pool::{ModelPool, Task};
|
||||
use crate::rl::{severity_reward, RlState};
|
||||
use crate::types::{Finding, RunConfig};
|
||||
use crate::report;
|
||||
@@ -45,6 +45,12 @@ fn tool_doctrine(mcp_on: bool) -> String {
|
||||
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\":\"...\"}.";
|
||||
|
||||
/// ReAct loop directive: make the agent reason → act with a tool → observe →
|
||||
/// iterate, instead of one-shot guessing. Keeps it grounded in real evidence.
|
||||
const REACT_DOCTRINE: &str = "METHOD (ReAct): work in explicit Thought → Action → Observation cycles. \
|
||||
Each Action runs ONE concrete tool command (e.g. a curl request); read its real Observation before the next Thought. \
|
||||
Base every claim on an actual observed response — never assume. Stop when you've either proven an issue or exhausted reasonable checks. Be token-efficient: no filler, no repetition.\n\n";
|
||||
|
||||
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
|
||||
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||
let _ = tx
|
||||
@@ -63,7 +69,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
"{}".to_string()
|
||||
} else {
|
||||
let recon_user = format!("{}Target: {}", tool_doctrine(pool.mcp_config.is_some()), cfg.target);
|
||||
match pool.complete(RECON_SYS, &recon_user).await {
|
||||
match pool.complete_routed(Task::Recon, RECON_SYS, &recon_user).await {
|
||||
Ok((m, t)) => {
|
||||
let _ = tx.send(format!("recon complete via {}", m.label())).await;
|
||||
if cfg.verbose {
|
||||
@@ -109,6 +115,11 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let _ = tx.send("selection empty — using recon-keyword heuristic".into()).await;
|
||||
heuristic_select(&ranked, &recon, cap)
|
||||
};
|
||||
// Dedup: never run the same agent twice in one engagement.
|
||||
let selected: Vec<Agent> = {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
selected.into_iter().filter(|a| seen.insert(a.name.clone())).collect()
|
||||
};
|
||||
let _ = tx
|
||||
.send(format!("intelligently selected {} agent(s) matching recon: {}", selected.len(),
|
||||
selected.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", ")))
|
||||
@@ -118,26 +129,29 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let target = cfg.target.clone();
|
||||
let verbose = cfg.verbose;
|
||||
let mcp_on = pool.mcp_config.is_some();
|
||||
// Token economy: each agent gets a capped recon context, not the full blob.
|
||||
let recon_ctx: String = recon.chars().take(3500).collect();
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
.map(|ag| {
|
||||
let target = target.clone();
|
||||
let recon = recon.clone();
|
||||
let recon = recon_ctx.clone();
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
let user = format!(
|
||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
||||
{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}}. \
|
||||
`evidence` must contain the concrete proof (request/response excerpt).",
|
||||
target = target,
|
||||
doctrine = tool_doctrine(mcp_on),
|
||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||
);
|
||||
if verbose {
|
||||
let _ = txc.send(format!(" ▶ launching agent: {} ({})", ag.name, ag.title.replace(" Agent", ""))).await;
|
||||
}
|
||||
match pool.complete(&ag.system, &user).await {
|
||||
let user = format!(
|
||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||
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 []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}. \
|
||||
`evidence` must contain the concrete proof (request/response excerpt).",
|
||||
target = target,
|
||||
react = REACT_DOCTRINE,
|
||||
doctrine = tool_doctrine(mcp_on),
|
||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||
);
|
||||
match pool.complete_routed(Task::Exploit, &ag.system, &user).await {
|
||||
Ok((m, text)) => {
|
||||
let f = extract_findings(&text, &ag.name);
|
||||
let _ = txc.send(format!("exploit {} via {} → {} candidate(s)", ag.name, m.label(), f.len())).await;
|
||||
@@ -155,8 +169,8 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
.await;
|
||||
|
||||
let transcript = transcript_of(&raw);
|
||||
let candidates: Vec<Finding> = raw.iter().flat_map(|(_, _, f)| f.clone()).collect();
|
||||
let _ = tx.send(format!("{} candidate finding(s) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
|
||||
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
||||
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
|
||||
|
||||
// ---- 4. Validate by N-model voting ---------------------------------
|
||||
let findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||
@@ -217,8 +231,8 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
|
||||
.await;
|
||||
|
||||
let transcript = transcript_of(&raw);
|
||||
let candidates: Vec<Finding> = raw.iter().flat_map(|(_, _, f)| f.clone()).collect();
|
||||
let _ = tx.send(format!("{} candidate finding(s) — validating", candidates.len())).await;
|
||||
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
|
||||
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
|
||||
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
|
||||
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
|
||||
}
|
||||
@@ -235,8 +249,10 @@ async fn select_agents(pool: &ModelPool, recon: &str, catalog: &[Agent], tx: &Se
|
||||
.map(|a| format!("{} — {} [{}]", a.name, a.title.replace(" Agent", ""), a.cwe))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let user = format!("RECON:\n{recon}\n\nAGENT CATALOG (name — title [cwe]):\n{list}\n\nReturn a JSON array of agent names to run.");
|
||||
match pool.complete(SELECT_SYS, &user).await {
|
||||
// Token economy: cap the recon blob fed to the selector.
|
||||
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.");
|
||||
match pool.complete_routed(Task::Select, SELECT_SYS, &user).await {
|
||||
Ok((m, text)) => {
|
||||
let names = parse_string_array(&text);
|
||||
if names.is_empty() {
|
||||
@@ -522,6 +538,20 @@ fn conf(v: Option<&serde_json::Value>) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop duplicate findings (same CWE + endpoint + lowercased title) that
|
||||
/// different agents/models may each report, keeping the highest-confidence one.
|
||||
fn dedup_findings(mut v: Vec<Finding>) -> Vec<Finding> {
|
||||
v.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
v.into_iter()
|
||||
.filter(|f| {
|
||||
let key = format!("{}|{}|{}", f.cwe.to_lowercase(), f.endpoint.to_lowercase(),
|
||||
f.title.to_lowercase().chars().take(40).collect::<String>());
|
||||
seen.insert(key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn norm_sev(s: &str) -> String {
|
||||
match s.to_lowercase().as_str() {
|
||||
x if x.starts_with("crit") => "Critical",
|
||||
|
||||
@@ -3,6 +3,22 @@ use anyhow::{anyhow, Result};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Task type used by the model router to pick the best model for the step.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Task {
|
||||
Recon,
|
||||
Select,
|
||||
Exploit,
|
||||
Validate,
|
||||
Default,
|
||||
}
|
||||
|
||||
/// Heuristic: is this a fast/cheap model id (good for recon/triage)?
|
||||
fn is_fast(model: &str) -> bool {
|
||||
let m = model.to_lowercase();
|
||||
["haiku", "flash", "fast", "mini", "lite", "chat", "small"].iter().any(|k| m.contains(k))
|
||||
}
|
||||
|
||||
/// A pool of candidate models with a global concurrency cap and provider
|
||||
/// failover. The same panel of models is reused for validator voting.
|
||||
///
|
||||
@@ -73,9 +89,17 @@ impl ModelPool {
|
||||
/// Complete a prompt, trying each candidate model until one succeeds.
|
||||
/// Returns the model that answered and its text.
|
||||
pub async fn complete(&self, system: &str, user: &str) -> Result<(ModelRef, String)> {
|
||||
self.complete_routed(Task::Default, system, user).await
|
||||
}
|
||||
|
||||
/// Router-aware completion: reorder the candidate panel by task before the
|
||||
/// failover loop. Recon/triage prefer a fast/cheap model to save tokens and
|
||||
/// latency; exploitation prefers the strongest (primary) model.
|
||||
pub async fn complete_routed(&self, task: Task, system: &str, user: &str) -> Result<(ModelRef, String)> {
|
||||
let _permit = self.sem.acquire().await.expect("semaphore closed");
|
||||
let order = self.route(task);
|
||||
let mut last = anyhow!("no candidate models");
|
||||
for m in &self.candidates {
|
||||
for m in &order {
|
||||
match self.one(m, system, user).await {
|
||||
Ok(text) => return Ok((m.clone(), text)),
|
||||
Err(e) => last = e,
|
||||
@@ -84,6 +108,25 @@ impl ModelPool {
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// Reorder candidates for a task. With a single-model panel this is a no-op.
|
||||
pub fn route(&self, task: Task) -> Vec<ModelRef> {
|
||||
let mut order = self.candidates.clone();
|
||||
if order.len() < 2 {
|
||||
return order;
|
||||
}
|
||||
match task {
|
||||
// Prefer a fast/cheap model for recon & selection.
|
||||
Task::Recon | Task::Select => {
|
||||
order.sort_by_key(|m| !is_fast(&m.model)); // fast first
|
||||
}
|
||||
// Strongest (panel order = primary first) for exploitation.
|
||||
Task::Exploit | Task::Default => {}
|
||||
// Validation handled by vote() rotation (different model than finder).
|
||||
Task::Validate => {}
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
|
||||
<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>{chips}</div><h2>Findings ({n})</h2>{body}\
|
||||
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.</p></body></html>",
|
||||
<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>",
|
||||
t = esc(target), chips = chips, n = sorted.len(), body = body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
#text(13pt)[Target: #strong(meta.target)]
|
||||
#v(4pt)
|
||||
#text(10pt, fill: gray)[Run #meta.run_id · #meta.generated · models: #meta.model]
|
||||
#v(8pt)
|
||||
#text(9pt, fill: gray)[by #strong[Joas A Santos] & #strong[Red Team Leaders]]
|
||||
]
|
||||
#pagebreak()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user