mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-06-30 07:15:30 +02:00
Merge NeuroSploit v3.4.0 — Rust multi-model harness into main
This commit is contained in:
@@ -91,3 +91,6 @@ logs/webgui.log
|
||||
# generated reports
|
||||
reports/report.*
|
||||
reports/*.pdf
|
||||
|
||||
# Rust build artifacts (v3.4.0)
|
||||
neurosploit-rs/target/
|
||||
|
||||
@@ -19,6 +19,21 @@ and a **reinforcement-learning** loop that gets smarter every run.
|
||||
|
||||
> The previous Python orchestration now lives in [`legacy/`](legacy/README.md).
|
||||
|
||||
> **🦀 v3.4.0 — Rust multi-model harness.** A new high-performance harness lives
|
||||
> in [`neurosploit-rs/`](neurosploit-rs/): a single Rust binary (`tokio` + `axum`)
|
||||
> that drives a **pool of LLM models** with concurrency, **provider failover**,
|
||||
> and **N-model validator voting** (N models must agree a finding is real before
|
||||
> it counts). It serves its own solid web dashboard. Build & run:
|
||||
> ```bash
|
||||
> cd neurosploit-rs && cargo build --release
|
||||
> ./target/release/neurosploit serve # web dashboard → :8788
|
||||
> ./target/release/neurosploit run https://target.example --model anthropic:claude-opus-4-8 --model openai:gpt-5.1
|
||||
> ./target/release/neurosploit run https://t.example --offline # pipeline self-test, no API keys
|
||||
> ```
|
||||
> 11 OpenAI-compatible providers / 31 models (Claude, GPT, Grok, NVIDIA NIM,
|
||||
> DeepSeek, Mistral, Qwen, Groq, Together, OpenRouter, Ollama). Reads the same
|
||||
> `agents_md/` library (213 agents).
|
||||
|
||||
---
|
||||
|
||||
## Why this architecture
|
||||
|
||||
+46
@@ -1,3 +1,49 @@
|
||||
# NeuroSploit v3.4.0 — Release Notes
|
||||
|
||||
**Release Date:** June 2026
|
||||
**Codename:** Rust Multi-Model Harness
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
A new **Rust harness** (`neurosploit-rs/`) re-implements the autonomous runtime
|
||||
as a single, fast binary built on `tokio` + `axum`. It drives a **pool of LLM
|
||||
models** with concurrency limits, **provider failover**, and **N-model validator
|
||||
voting** — multiple models must independently agree a finding is real before it
|
||||
is reported — then serves its own solid web dashboard. It reuses the existing
|
||||
`agents_md/` library (213 agents) unchanged.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **`neurosploit-rs/` cargo workspace**: `harness` lib crate + `neurosploit`
|
||||
binary. `cargo build --release` → one static-ish binary.
|
||||
- **Multi-model pool** (`pool.rs`): bounded concurrency + automatic **failover**
|
||||
across providers; the same panel is reused as the **validator voting** jury.
|
||||
- **Pipeline** (`pipeline.rs`): recon → parallel agent exploitation (semaphore
|
||||
bounded) → **N-model adversarial vote** → score → report. Streams live
|
||||
progress over a channel.
|
||||
- **11 providers / 31 models** (`models.rs`), all OpenAI-compatible: Anthropic,
|
||||
OpenAI, xAI, NVIDIA NIM, DeepSeek, Mistral, Qwen, Groq, Together, OpenRouter,
|
||||
Ollama. Models like **Qwen / DeepSeek / Llama** usable directly.
|
||||
- **Axum web dashboard** (`app/`): multi-model selection panel, live execution
|
||||
console, findings, agent browser, embedded HTML report. Single binary serves
|
||||
the SPA — no npm/build.
|
||||
- **CLI**: `neurosploit serve | run <url> | agents | models`, plus `--offline`
|
||||
mode to exercise the full pipeline without any API keys.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cd neurosploit-rs && cargo build --release
|
||||
./target/release/neurosploit serve # → http://127.0.0.1:8788
|
||||
./target/release/neurosploit run https://t.example \
|
||||
--model anthropic:claude-opus-4-8 --model openai:gpt-5.1 --vote-n 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# NeuroSploit v3.3.0 — Release Notes
|
||||
|
||||
**Release Date:** June 2026
|
||||
|
||||
Generated
+2019
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[workspace]
|
||||
members = ["crates/harness", "app"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "3.4.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/JoasASantos/NeuroSploit"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
anyhow = "1"
|
||||
futures = "0.3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "neurosploit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "neurosploit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
harness = { package = "neurosploit-harness", path = "../crates/harness" }
|
||||
serde.workspace = true
|
||||
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"] }
|
||||
@@ -0,0 +1,122 @@
|
||||
//! NeuroSploit v3.4.0 — single binary: `serve` (web dashboard) or `run` (CLI).
|
||||
|
||||
mod web;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use harness::{agents, models::ModelRef, pool::ModelPool, report, types::RunConfig};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "neurosploit", version, about = "NeuroSploit v3.4.0 — multi-model autonomous pentest harness")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Start the web dashboard.
|
||||
Serve {
|
||||
#[arg(long, default_value_t = 8788)]
|
||||
port: u16,
|
||||
},
|
||||
/// Run an engagement from the CLI.
|
||||
Run {
|
||||
url: String,
|
||||
/// Models as provider:model (repeatable). First is primary; rest fail over + vote.
|
||||
#[arg(long = "model")]
|
||||
models: Vec<String>,
|
||||
#[arg(long, default_value_t = 0)]
|
||||
max_agents: usize,
|
||||
#[arg(long, default_value_t = 3)]
|
||||
vote_n: usize,
|
||||
/// Exercise the pipeline without calling any model API.
|
||||
#[arg(long)]
|
||||
offline: bool,
|
||||
},
|
||||
/// Show agent library counts.
|
||||
Agents,
|
||||
/// List providers and models.
|
||||
Models,
|
||||
}
|
||||
|
||||
/// Locate the repo root that holds `agents_md/` (walk up from CWD, then fall
|
||||
/// back to the crate's compile-time location).
|
||||
fn find_base() -> PathBuf {
|
||||
if let Ok(b) = std::env::var("NEUROSPLOIT_BASE") {
|
||||
return PathBuf::from(b);
|
||||
}
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
let mut dir = cwd.as_path();
|
||||
for _ in 0..6 {
|
||||
if dir.join("agents_md").is_dir() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
match dir.parent() {
|
||||
Some(p) => dir = p,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
// crate is at <root>/neurosploit-rs/app → root is two levels up
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let base = find_base();
|
||||
|
||||
match cli.cmd {
|
||||
Cmd::Agents => {
|
||||
let lib = agents::load(&base);
|
||||
println!("{{\"vulns\":{},\"meta\":{},\"total\":{}}}", lib.vulns.len(), lib.meta.len(), lib.total());
|
||||
}
|
||||
Cmd::Models => {
|
||||
for p in harness::providers() {
|
||||
println!("{:<4} {:<14} {} models [{}]", p.kind, p.key, p.models.len(), p.label);
|
||||
for m in &p.models {
|
||||
println!(" {}:{}", p.key, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Run { url, models, max_agents, vote_n, offline } => {
|
||||
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;
|
||||
if !models.is_empty() {
|
||||
cfg.models = models;
|
||||
}
|
||||
let lib = agents::load(&base);
|
||||
let refs: Vec<ModelRef> = cfg.models.iter().map(|s| ModelRef::parse(s)).collect();
|
||||
let pool = ModelPool::new(refs, cfg.concurrency);
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(256);
|
||||
let printer = tokio::spawn(async move {
|
||||
while let Some(line) = rx.recv().await {
|
||||
println!(" [*] {line}");
|
||||
}
|
||||
});
|
||||
let out = harness::run(cfg.clone(), &lib, &pool, tx).await;
|
||||
let _ = printer.await;
|
||||
|
||||
println!("\n=== {} validated finding(s) ===", out.findings.len());
|
||||
println!("{}", serde_json::to_string_pretty(&out.findings)?);
|
||||
let html = report::html(&url, &out.findings);
|
||||
std::fs::create_dir_all(base.join("reports")).ok();
|
||||
let rp = base.join("reports").join("report_rs.html");
|
||||
std::fs::write(&rp, html).ok();
|
||||
println!("report → {}", rp.display());
|
||||
}
|
||||
Cmd::Serve { port } => {
|
||||
web::serve(base, port).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Axum web dashboard for the v3.4.0 harness.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::Html,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use harness::{agents, models::ModelRef, pool::ModelPool, report, types::RunConfig};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
struct RunState {
|
||||
log: Vec<String>,
|
||||
done: bool,
|
||||
result: Option<Value>,
|
||||
report: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
base: PathBuf,
|
||||
runs: Mutex<HashMap<String, RunState>>,
|
||||
}
|
||||
|
||||
pub async fn serve(base: PathBuf, port: u16) -> anyhow::Result<()> {
|
||||
let state = Arc::new(AppState { base, runs: Mutex::new(HashMap::new()) });
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/api/info", get(info))
|
||||
.route("/api/agents", get(agents_list))
|
||||
.route("/api/models", get(models_list))
|
||||
.route("/api/run", post(run))
|
||||
.route("/api/status/:id", get(status))
|
||||
.route("/report/:id", get(report_html))
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
println!("NeuroSploit v3.4.0 dashboard → http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(include_str!("../web/index.html"))
|
||||
}
|
||||
|
||||
async fn info(State(st): State<Arc<AppState>>) -> Json<Value> {
|
||||
let lib = agents::load(&st.base);
|
||||
let provs: Vec<Value> = harness::providers()
|
||||
.iter()
|
||||
.map(|p| json!({"key": p.key, "label": p.label, "kind": p.kind, "models": p.models}))
|
||||
.collect();
|
||||
Json(json!({
|
||||
"version": "3.4.0",
|
||||
"agents": {"vulns": lib.vulns.len(), "meta": lib.meta.len(), "total": lib.total()},
|
||||
"providers": provs,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn agents_list(State(st): State<Arc<AppState>>) -> Json<Value> {
|
||||
let lib = agents::load(&st.base);
|
||||
let v: Vec<Value> = lib
|
||||
.vulns
|
||||
.iter()
|
||||
.chain(lib.meta.iter())
|
||||
.map(|a| json!({"name": a.name, "title": a.title, "cwe": a.cwe, "kind": a.kind}))
|
||||
.collect();
|
||||
Json(json!({ "agents": v }))
|
||||
}
|
||||
|
||||
async fn models_list() -> Json<Value> {
|
||||
let provs: Vec<Value> = harness::providers()
|
||||
.iter()
|
||||
.map(|p| json!({"key": p.key, "label": p.label, "kind": p.kind, "models": p.models}))
|
||||
.collect();
|
||||
Json(json!({ "providers": provs }))
|
||||
}
|
||||
|
||||
fn norm(u: &str) -> String {
|
||||
if u.starts_with("http") {
|
||||
u.to_string()
|
||||
} else {
|
||||
format!("https://{u}")
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(State(st): State<Arc<AppState>>, Json(body): Json<Value>) -> Json<Value> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
st.runs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.clone(), RunState { log: vec![], done: false, result: None, report: None });
|
||||
|
||||
let st2 = st.clone();
|
||||
let id2 = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let base = st2.base.clone();
|
||||
|
||||
let mut targets: Vec<String> = Vec::new();
|
||||
if let Some(arr) = body.get("targets").and_then(|v| v.as_array()) {
|
||||
for t in arr {
|
||||
if let Some(s) = t.as_str() {
|
||||
if !s.trim().is_empty() {
|
||||
targets.push(norm(s.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
if let Some(u) = body.get("url").and_then(|v| v.as_str()) {
|
||||
if !u.trim().is_empty() {
|
||||
targets.push(norm(u.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let models: Vec<String> = body
|
||||
.get("models")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
let vote_n = body.get("vote_n").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
|
||||
let max_agents = body.get("max_agents").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let offline = body.get("offline").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let lib = agents::load(&base);
|
||||
let refs: Vec<ModelRef> = if models.is_empty() {
|
||||
vec![ModelRef::parse("anthropic:claude-opus-4-8")]
|
||||
} else {
|
||||
models.iter().map(|s| ModelRef::parse(s)).collect()
|
||||
};
|
||||
let pool = ModelPool::new(refs, 8);
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(256);
|
||||
let stf = st2.clone();
|
||||
let idf = id2.clone();
|
||||
let fwd = tokio::spawn(async move {
|
||||
while let Some(line) = rx.recv().await {
|
||||
if let Ok(mut g) = stf.runs.lock() {
|
||||
if let Some(r) = g.get_mut(&idf) {
|
||||
r.log.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut all_findings = Vec::new();
|
||||
let mut all_ran = Vec::new();
|
||||
for url in &targets {
|
||||
let mut cfg = RunConfig::new(url);
|
||||
cfg.models = if models.is_empty() {
|
||||
vec!["anthropic:claude-opus-4-8".into()]
|
||||
} else {
|
||||
models.clone()
|
||||
};
|
||||
cfg.vote_n = vote_n;
|
||||
cfg.max_agents = max_agents;
|
||||
cfg.offline = offline;
|
||||
let _ = tx.send(format!("=== target: {url} ===")).await;
|
||||
let out = harness::run(cfg, &lib, &pool, tx.clone()).await;
|
||||
all_findings.extend(out.findings);
|
||||
all_ran.extend(out.agents_ran);
|
||||
}
|
||||
drop(tx);
|
||||
let _ = fwd.await;
|
||||
|
||||
let report_html = report::html(targets.first().map(|s| s.as_str()).unwrap_or(""), &all_findings);
|
||||
let result = json!({"findings": all_findings, "agents_ran": all_ran, "targets": targets});
|
||||
if let Ok(mut g) = st2.runs.lock() {
|
||||
if let Some(r) = g.get_mut(&id2) {
|
||||
r.result = Some(result);
|
||||
r.report = Some(report_html);
|
||||
r.done = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Json(json!({ "run_id": id }))
|
||||
}
|
||||
|
||||
async fn status(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Json<Value> {
|
||||
let g = st.runs.lock().unwrap();
|
||||
match g.get(&id) {
|
||||
Some(r) => Json(json!({"log": r.log, "done": r.done, "result": r.result, "has_report": r.report.is_some()})),
|
||||
None => Json(json!({"error": "unknown run"})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn report_html(Path(id): Path<String>, State(st): State<Arc<AppState>>) -> Html<String> {
|
||||
let g = st.runs.lock().unwrap();
|
||||
Html(g.get(&id).and_then(|r| r.report.clone()).unwrap_or_else(|| "<h1>no report</h1>".into()))
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>NeuroSploit v3.4.0</title>
|
||||
<style>
|
||||
:root{--bg:#080910;--bg2:#0d0f17;--panel:#13151f;--panel2:#1a1d29;--line:#252938;--text:#e7e9ee;
|
||||
--muted:#878da1;--accent:#8b5cf6;--accent2:#a855f7;--cy:#22d3ee;--ok:#34d399;--warn:#fbbf24;--crit:#f87171;--high:#fb923c}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif;
|
||||
display:grid;grid-template-columns:228px 1fr;min-height:100vh}
|
||||
.side{background:linear-gradient(180deg,var(--bg2),#0a0b12);border-right:1px solid var(--line);padding:20px 14px;
|
||||
display:flex;flex-direction:column;gap:4px;position:sticky;top:0;height:100vh}
|
||||
.brand{display:flex;align-items:center;gap:10px;margin:2px 6px 20px}
|
||||
.logo{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,var(--accent),var(--accent2));
|
||||
display:grid;place-items:center;font-weight:800;color:#fff;box-shadow:0 6px 22px rgba(139,92,246,.4)}
|
||||
.brand b{font-size:15px}.brand span{color:var(--muted);font-size:11px;display:block;margin-top:-2px}
|
||||
.nav{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:9px;color:var(--muted);cursor:pointer;font-size:13.5px}
|
||||
.nav:hover{background:var(--panel);color:var(--text)}
|
||||
.nav.on{background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(168,85,247,.12));color:#fff;box-shadow:inset 0 0 0 1px rgba(139,92,246,.35)}
|
||||
.sf{margin-top:auto;font-size:11px;color:var(--muted);padding:10px 8px;border-top:1px solid var(--line)}
|
||||
.badge-rs{display:inline-block;font-size:9px;font-weight:700;color:#fff;background:#dea584;border-radius:4px;padding:1px 5px;margin-left:6px;vertical-align:middle}
|
||||
main{padding:26px 32px;max-width:1060px}
|
||||
h1{font-size:20px;margin:0}.sub{color:var(--muted);font-size:12.5px}
|
||||
.head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:18px}
|
||||
.view{display:none}.view.on{display:block}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px}
|
||||
label{display:block;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin:0 0 6px}
|
||||
.field{margin-bottom:15px}
|
||||
input,select,textarea{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--text);border-radius:9px;
|
||||
padding:10px 11px;font-size:13.5px;outline:none;font-family:inherit}
|
||||
textarea{resize:vertical;min-height:70px;font-family:ui-monospace,Menlo,monospace;font-size:12.5px}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
|
||||
.row{display:flex;gap:12px}.row>*{flex:1}
|
||||
.mpanel{max-height:200px;overflow:auto;border:1px solid var(--line);border-radius:9px;padding:6px}
|
||||
.mopt{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:7px;font-size:12.5px;cursor:pointer}
|
||||
.mopt:hover{background:var(--panel2)}.mopt input{accent-color:var(--accent);width:15px;height:15px}
|
||||
.kind{font-size:9px;padding:1px 6px;border-radius:4px;background:var(--line);color:var(--muted);text-transform:uppercase}
|
||||
.kind.cli{background:rgba(139,92,246,.2);color:#c4b5fd}.kind.meta{background:rgba(34,211,238,.14);color:var(--cy)}
|
||||
.toggles{display:flex;gap:10px;flex-wrap:wrap;margin:6px 0 14px}
|
||||
.toggle{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:9px 12px;cursor:pointer;font-size:12.5px}
|
||||
.toggle.on{border-color:var(--accent)}.toggle input{accent-color:var(--accent)}
|
||||
.btns{display:flex;gap:10px}
|
||||
button{border:0;border-radius:10px;padding:11px 16px;font-size:13.5px;font-weight:600;cursor:pointer}
|
||||
.run{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;flex:1}.run:hover{filter:brightness(1.08)}
|
||||
.ghost{background:var(--panel2);color:var(--text);border:1px solid var(--line)}.ghost:hover{border-color:var(--accent)}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.pill{display:inline-flex;gap:5px;background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:4px 11px;font-size:11.5px;color:var(--muted);margin-right:7px}
|
||||
.pill b{color:var(--text)}
|
||||
.term{background:#06070c;border:1px solid var(--line);border-radius:11px;padding:13px 15px;margin-top:14px;
|
||||
font:12px/1.6 ui-monospace,Menlo,monospace;max-height:260px;overflow:auto;white-space:pre-wrap;color:#cbd3e6;display:none}
|
||||
.term .h{color:var(--accent2)}.term .ok{color:var(--ok)}.term .e{color:var(--crit)}.term .v{color:var(--cy)}
|
||||
.sevrow{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0;display:none}
|
||||
.sev{border-radius:8px;padding:5px 11px;font-size:12px;font-weight:700}
|
||||
.sev.Critical{background:rgba(248,113,113,.16);color:var(--crit)}.sev.High{background:rgba(251,146,60,.16);color:var(--high)}
|
||||
.sev.Medium{background:rgba(251,191,36,.15);color:var(--warn)}.sev.Low{background:rgba(34,211,238,.14);color:var(--cy)}
|
||||
.sev.none{background:rgba(52,211,153,.13);color:var(--ok)}
|
||||
.find{border:1px solid var(--line);border-radius:11px;padding:14px;margin:10px 0;background:var(--panel2)}
|
||||
.find h4{margin:0 0 5px;font-size:14px}.find .m{color:var(--muted);font-size:12px}
|
||||
.find pre{background:#06070c;border-radius:7px;padding:9px;font-size:11.5px;overflow:auto;margin:7px 0}
|
||||
.alist{max-height:430px;overflow:auto;border:1px solid var(--line);border-radius:10px}
|
||||
.arow{display:flex;gap:10px;padding:9px 13px;border-bottom:1px solid var(--line);font-size:13px;align-items:center}
|
||||
.arow:last-child{border:0}.arow code{color:var(--accent2)}.arow .t{color:var(--muted);margin-left:auto;font-size:11.5px}
|
||||
.muted{color:var(--muted);font-size:12.5px}a{color:var(--accent2)}
|
||||
.dl{display:inline-flex;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:8px 13px;text-decoration:none;color:var(--text);font-size:12.5px}
|
||||
.dl:hover{border-color:var(--accent)}iframe{width:100%;height:520px;border:1px solid var(--line);border-radius:10px;background:#fff;margin-top:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="side">
|
||||
<div class="brand"><div class="logo">N</div><div><b>NeuroSploit<span class="badge-rs">RUST</span></b><span>v3.4.0 · Multi-Model Harness</span></div></div>
|
||||
<div class="nav on" data-v="run">▶ Run</div>
|
||||
<div class="nav" data-v="agents">⛓ Agents</div>
|
||||
<div class="nav" data-v="models">🧠 Models</div>
|
||||
<div class="nav" data-v="reports">📄 Report</div>
|
||||
<div class="sf" id="sf">loading…</div>
|
||||
</aside>
|
||||
<main>
|
||||
<section class="view on" id="v-run">
|
||||
<div class="head"><div><h1>Run engagement</h1><div class="sub">Parallel agents · provider failover · N-model validator voting.</div></div></div>
|
||||
<div class="card">
|
||||
<div class="field"><label>Targets (one URL per line)</label><textarea id="targets" placeholder="https://target-one.example https://target-two.example"></textarea></div>
|
||||
<div class="field"><label>Model panel (1st = primary · others fail over & vote)</label><div class="mpanel" id="mpanel"></div></div>
|
||||
<div class="row">
|
||||
<div class="field"><label>Validator votes (N)</label><input id="voten" type="number" value="3" min="1" max="9"/></div>
|
||||
<div class="field"><label>Max agents (0 = all)</label><input id="maxa" type="number" value="8" min="0"/></div>
|
||||
</div>
|
||||
<div class="toggles"><label class="toggle on" id="t-off"><input type="checkbox" id="offline" checked/> Offline (no API calls — pipeline self-test)</label></div>
|
||||
<div class="btns"><button class="run" id="go">▶ Run harness</button></div>
|
||||
<div class="term" id="term"></div>
|
||||
<div class="sevrow" id="sevrow"></div>
|
||||
<div id="findings"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="view" id="v-agents">
|
||||
<div class="head"><div><h1>Agent library</h1><div class="sub" id="agentsub">…</div></div></div>
|
||||
<div class="card"><div class="field"><input id="asearch" placeholder="🔎 filter agents"/></div><div class="alist" id="alist"></div></div>
|
||||
</section>
|
||||
<section class="view" id="v-models">
|
||||
<div class="head"><div><h1>Models</h1><div class="sub">OpenAI-compatible providers — CLI & API.</div></div></div>
|
||||
<div class="card" id="modelcard"></div>
|
||||
</section>
|
||||
<section class="view" id="v-reports">
|
||||
<div class="head"><div><h1>Report</h1><div class="sub">Last engagement.</div></div></div>
|
||||
<div class="card" id="reportcard"><span class="muted">Run an engagement to generate a report.</span></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
|
||||
let INFO=null,AGENTS=[],lastRun=null;
|
||||
$$('.nav').forEach(n=>n.onclick=()=>{$$('.nav').forEach(x=>x.classList.remove('on'));n.classList.add('on');
|
||||
$$('.view').forEach(v=>v.classList.remove('on'));$('#v-'+n.dataset.v).classList.add('on');});
|
||||
$('#t-off').querySelector('input').onchange=e=>$('#t-off').classList.toggle('on',e.target.checked);
|
||||
|
||||
async function init(){
|
||||
INFO=await (await fetch('/api/info')).json();
|
||||
$('#sf').textContent=`${INFO.agents.total} agents · ${INFO.providers.length} providers`;
|
||||
$('#agentsub').textContent=`${INFO.agents.vulns} vuln specialists · ${INFO.agents.meta} meta-agents`;
|
||||
// model panel
|
||||
let mh='',first=true;
|
||||
INFO.providers.forEach(p=>p.models.forEach(m=>{const id=p.key+':'+m;
|
||||
mh+=`<label class="mopt"><input type="checkbox" value="${id}" ${first?'checked':''}/> <span class="kind ${p.kind}">${p.kind}</span> <code>${id}</code></label>`;first=false;}));
|
||||
$('#mpanel').innerHTML=mh;
|
||||
// models tab
|
||||
$('#modelcard').innerHTML=INFO.providers.map(p=>`<div style="margin-bottom:12px"><b>${p.label}</b> <span class="kind ${p.kind}">${p.kind}</span>
|
||||
<div class="muted" style="margin-top:4px">${p.models.map(m=>'<code>'+m+'</code>').join(' · ')}</div></div>`).join('');
|
||||
AGENTS=(await (await fetch('/api/agents')).json()).agents;renderAgents();
|
||||
}
|
||||
function selectedModels(){return $$('#mpanel input:checked').map(i=>i.value);}
|
||||
function logLine(t){const T=$('#term');T.style.display='block';const d=document.createElement('div');
|
||||
d.className=t.startsWith('===')?'h':t.startsWith('vote')&&t.includes('CONFIRMED')?'ok':t.includes('failed')||t.startsWith('ERROR')?'e':t.startsWith('recon')||t.startsWith('exploit')?'v':'';
|
||||
d.textContent=t;T.appendChild(d);T.scrollTop=T.scrollHeight;}
|
||||
let seen=0;
|
||||
async function run(){
|
||||
const targets=$('#targets').value.split('\n').map(s=>s.trim()).filter(Boolean);
|
||||
if(!targets.length){$('#targets').focus();$('#targets').style.borderColor='var(--crit)';return;}
|
||||
$('#go').disabled=true;$('#term').innerHTML='';$('#sevrow').style.display='none';$('#findings').innerHTML='';
|
||||
const body={targets,models:selectedModels(),vote_n:+$('#voten').value,max_agents:+$('#maxa').value,offline:$('#offline').checked};
|
||||
logLine('Queued '+targets.length+' target(s) · panel: '+(body.models.join(', ')||'default'));
|
||||
const r=await (await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
|
||||
if(r.error){logLine('ERROR: '+r.error);$('#go').disabled=false;return;}
|
||||
poll(r.run_id);
|
||||
}
|
||||
async function poll(id){
|
||||
const st=await (await fetch('/api/status/'+id)).json();
|
||||
(st.log||[]).slice(seen).forEach(logLine);seen=(st.log||[]).length;
|
||||
if(!st.done){setTimeout(()=>poll(id),600);return;}
|
||||
seen=0;$('#go').disabled=false;lastRun=id;render(st.result||{});
|
||||
}
|
||||
function render(res){
|
||||
const sr=$('#sevrow');sr.style.display='flex';
|
||||
const f=res.findings||[],by={};f.forEach(x=>by[x.severity]=(by[x.severity]||0)+1);
|
||||
sr.innerHTML=f.length?Object.entries(by).map(([k,v])=>`<span class="sev ${k}">${k}: ${v}</span>`).join('')
|
||||
:`<span class="sev none">✓ complete — ${(res.agents_ran||[]).length} agents ran, 0 validated findings</span>`;
|
||||
$('#findings').innerHTML=f.map(x=>`<div class="find"><h4><span class="sev ${x.severity}" style="font-size:11px">${x.severity}</span> ${x.title||''}</h4>
|
||||
<div class="m">${x.agent||''} · ${x.cwe||''} · votes ${x.votes||'-'} · conf ${(x.confidence||0).toFixed(2)} · ${x.endpoint||''}</div>
|
||||
${x.payload?`<pre>${(x.payload+'').replace(/</g,'<')}</pre>`:''}${x.evidence?`<div class="m">Evidence: ${x.evidence}</div>`:''}</div>`).join('');
|
||||
const rc=$('#reportcard');
|
||||
rc.innerHTML=`<a class="dl" href="/report/${lastRun}" target="_blank">⬇ open HTML report</a><iframe src="/report/${lastRun}"></iframe>`;
|
||||
}
|
||||
function renderAgents(){const q=$('#asearch').value.toLowerCase();
|
||||
$('#alist').innerHTML=AGENTS.filter(a=>!q||(a.name+a.title+a.cwe).toLowerCase().includes(q)).slice(0,400)
|
||||
.map(a=>`<div class="arow"><span class="kind ${a.kind}">${a.kind}</span> <code>${a.name}</code> <span class="t">${(a.title||'').replace(' Agent','')} ${a.cwe?'· '+a.cwe:''}</span></div>`).join('')||'<div class="arow muted">no match</div>';}
|
||||
$('#asearch').oninput=renderAgents;
|
||||
$('#go').onclick=run;$('#targets').oninput=()=>$('#targets').style.borderColor='';
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "neurosploit-harness"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "harness"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
walkdir = "2"
|
||||
regex = "1"
|
||||
@@ -0,0 +1,77 @@
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// One markdown specialist/meta agent.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Agent {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub cwe: String,
|
||||
pub kind: String, // "vuln" | "meta"
|
||||
#[serde(skip)]
|
||||
pub system: String,
|
||||
#[serde(skip)]
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// The loaded `agents_md/` library.
|
||||
#[derive(Default)]
|
||||
pub struct Library {
|
||||
pub vulns: Vec<Agent>,
|
||||
pub meta: Vec<Agent>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn total(&self) -> usize {
|
||||
self.vulns.len() + self.meta.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `<base>/agents_md/{vulns,meta}/*.md`.
|
||||
pub fn load(base: &Path) -> Library {
|
||||
let root = base.join("agents_md");
|
||||
Library {
|
||||
vulns: load_dir(&root.join("vulns"), "vuln"),
|
||||
meta: load_dir(&root.join("meta"), "meta"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_dir(dir: &Path, kind: &str) -> Vec<Agent> {
|
||||
let title_re = Regex::new(r"(?m)^#\s+(.+?)\s*$").unwrap();
|
||||
let cwe_re = Regex::new(r"CWE-\d+").unwrap();
|
||||
let user_re = Regex::new(r"(?s)##\s*User Prompt\s*\n(.*?)(?:\n##\s|\z)").unwrap();
|
||||
let sys_re = Regex::new(r"(?s)##\s*System Prompt\s*\n(.*?)(?:\n##\s|\z)").unwrap();
|
||||
let mut out = Vec::new();
|
||||
if !dir.is_dir() {
|
||||
return out;
|
||||
}
|
||||
for entry in WalkDir::new(dir).max_depth(1).into_iter().flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
|
||||
let title = title_re
|
||||
.captures(&text)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.unwrap_or_else(|| name.clone());
|
||||
let cwe = cwe_re.find(&text).map(|m| m.as_str().to_string()).unwrap_or_default();
|
||||
let user = user_re
|
||||
.captures(&text)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let system = sys_re
|
||||
.captures(&text)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.unwrap_or_default();
|
||||
out.push(Agent { name, title, cwe, kind: kind.to_string(), system, user });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! NeuroSploit v3.4.0 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
|
||||
//! (any OpenAI-compatible provider) with concurrency + provider failover, runs
|
||||
//! the specialist agents in parallel, then validates every candidate finding by
|
||||
//! **N-model voting** before scoring and reporting.
|
||||
|
||||
pub mod agents;
|
||||
pub mod models;
|
||||
pub mod pipeline;
|
||||
pub mod pool;
|
||||
pub mod report;
|
||||
pub mod types;
|
||||
|
||||
pub use agents::{Agent, Library};
|
||||
pub use models::{provider_for, providers, ChatClient, ModelRef, Provider};
|
||||
pub use pipeline::run;
|
||||
pub use pool::ModelPool;
|
||||
pub use types::{Finding, RunConfig};
|
||||
@@ -0,0 +1,134 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A model provider exposing an OpenAI-compatible `/chat/completions` endpoint.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Provider {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
pub base_url: &'static str,
|
||||
pub env_key: &'static str,
|
||||
/// "cli" (also drivable by an agentic CLI) | "api"
|
||||
pub kind: &'static str,
|
||||
pub models: Vec<&'static str>,
|
||||
}
|
||||
|
||||
/// The full provider registry. Every entry speaks the OpenAI chat schema
|
||||
/// (Anthropic, xAI, NVIDIA NIM, DeepSeek, Mistral, Qwen, Groq, Together,
|
||||
/// OpenRouter, Gemini-compat, Ollama).
|
||||
pub fn providers() -> Vec<Provider> {
|
||||
vec![
|
||||
Provider { key: "anthropic", label: "Anthropic Claude", base_url: "https://api.anthropic.com/v1", env_key: "ANTHROPIC_API_KEY", kind: "cli",
|
||||
models: vec!["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"] },
|
||||
Provider { key: "openai", label: "OpenAI", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli",
|
||||
models: vec!["gpt-5.1", "o4"] },
|
||||
Provider { key: "xai", label: "xAI Grok", base_url: "https://api.x.ai/v1", env_key: "XAI_API_KEY", kind: "cli",
|
||||
models: vec!["grok-4", "grok-4-fast"] },
|
||||
Provider { key: "nvidia_nim", label: "NVIDIA NIM", base_url: "https://integrate.api.nvidia.com/v1", env_key: "NVIDIA_NIM_API_KEY", kind: "api",
|
||||
models: vec!["nvidia/llama-3.3-nemotron-super-49b-v1", "deepseek-ai/deepseek-r1", "qwen/qwen2.5-coder-32b-instruct"] },
|
||||
Provider { key: "deepseek", label: "DeepSeek", base_url: "https://api.deepseek.com/v1", env_key: "DEEPSEEK_API_KEY", kind: "api",
|
||||
models: vec!["deepseek-reasoner", "deepseek-chat"] },
|
||||
Provider { key: "mistral", label: "Mistral", base_url: "https://api.mistral.ai/v1", env_key: "MISTRAL_API_KEY", kind: "api",
|
||||
models: vec!["mistral-large-latest", "codestral-latest"] },
|
||||
Provider { key: "qwen", label: "Qwen (DashScope)", base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", env_key: "DASHSCOPE_API_KEY", kind: "api",
|
||||
models: vec!["qwen-max", "qwen2.5-coder-32b-instruct", "qwq-plus"] },
|
||||
Provider { key: "groq", label: "Groq", base_url: "https://api.groq.com/openai/v1", env_key: "GROQ_API_KEY", kind: "api",
|
||||
models: vec!["llama-3.3-70b-versatile", "qwen-2.5-coder-32b"] },
|
||||
Provider { key: "together", label: "Together AI", base_url: "https://api.together.xyz/v1", env_key: "TOGETHER_API_KEY", kind: "api",
|
||||
models: vec!["Qwen/Qwen2.5-Coder-32B-Instruct", "deepseek-ai/DeepSeek-R1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"] },
|
||||
Provider { key: "openrouter", label: "OpenRouter", base_url: "https://openrouter.ai/api/v1", env_key: "OPENROUTER_API_KEY", kind: "api",
|
||||
models: vec!["anthropic/claude-opus-4-8", "qwen/qwen-2.5-coder-32b-instruct", "deepseek/deepseek-r1", "meta-llama/llama-3.3-70b-instruct"] },
|
||||
Provider { key: "ollama", label: "Ollama (local)", base_url: "http://localhost:11434/v1", env_key: "OLLAMA_API_KEY", kind: "api",
|
||||
models: vec!["qwen2.5-coder:32b", "qwq:32b", "deepseek-r1:32b", "llama3.3:70b"] },
|
||||
]
|
||||
}
|
||||
|
||||
pub fn provider_for(key: &str) -> Option<Provider> {
|
||||
providers().into_iter().find(|p| p.key == key)
|
||||
}
|
||||
|
||||
/// A `provider:model` selection.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelRef {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl ModelRef {
|
||||
pub fn parse(s: &str) -> ModelRef {
|
||||
match s.split_once(':') {
|
||||
Some((p, m)) => ModelRef { provider: p.to_string(), model: m.to_string() },
|
||||
None => ModelRef { provider: "anthropic".into(), model: s.to_string() },
|
||||
}
|
||||
}
|
||||
pub fn label(&self) -> String {
|
||||
format!("{}:{}", self.provider, self.model)
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAI-compatible chat client shared across the model pool.
|
||||
#[derive(Clone)]
|
||||
pub struct ChatClient {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ChatClient {
|
||||
pub fn new() -> Self {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
ChatClient { http }
|
||||
}
|
||||
|
||||
/// One chat completion. Errors (missing key, network, non-2xx) propagate so
|
||||
/// the pool can fail over to the next candidate model.
|
||||
pub async fn chat(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
|
||||
let p = provider_for(&m.provider)
|
||||
.ok_or_else(|| anyhow!("unknown provider '{}'", m.provider))?;
|
||||
let key = std::env::var(p.env_key).unwrap_or_default();
|
||||
if key.is_empty() && p.key != "ollama" {
|
||||
return Err(anyhow!("no API key ({}) for provider '{}'", p.env_key, p.key));
|
||||
}
|
||||
let url = format!("{}/chat/completions", p.base_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"model": m.model,
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.2,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}
|
||||
]
|
||||
});
|
||||
let mut req = self.http.post(&url).json(&body);
|
||||
if !key.is_empty() {
|
||||
req = req.bearer_auth(&key);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(anyhow!("{} returned {}: {}", p.key, status, truncate(&text, 200)));
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(&text)?;
|
||||
let content = v["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("no content in response"))?;
|
||||
Ok(content.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..n])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use crate::agents::{Agent, Library};
|
||||
use crate::pool::ModelPool;
|
||||
use crate::types::{Finding, RunConfig};
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
/// Result of an engagement run.
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct RunOutput {
|
||||
pub findings: Vec<Finding>,
|
||||
pub agents_ran: Vec<String>,
|
||||
pub candidates: usize,
|
||||
}
|
||||
|
||||
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 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.";
|
||||
|
||||
/// Run the full harness pipeline, streaming human-readable progress over `tx`.
|
||||
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||
let _ = tx
|
||||
.send(format!(
|
||||
"Loaded {} agents ({} vuln / {} meta) · models: {} · vote_n={} · concurrency={}",
|
||||
lib.total(),
|
||||
lib.vulns.len(),
|
||||
lib.meta.len(),
|
||||
pool.candidates.iter().map(|m| m.label()).collect::<Vec<_>>().join(", "),
|
||||
cfg.vote_n,
|
||||
cfg.concurrency,
|
||||
))
|
||||
.await;
|
||||
|
||||
// ---- 1. Recon -------------------------------------------------------
|
||||
let recon = if cfg.offline {
|
||||
let _ = tx.send("recon: offline mode — skipping model calls".into()).await;
|
||||
"{}".to_string()
|
||||
} else {
|
||||
match pool.complete(RECON_SYS, &format!("Target: {}", cfg.target)).await {
|
||||
Ok((m, t)) => {
|
||||
let _ = tx.send(format!("recon complete via {}", m.label())).await;
|
||||
t
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(format!("recon failed ({e}) — continuing with empty recon")).await;
|
||||
"{}".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 2. Select agents ----------------------------------------------
|
||||
let cap = if cfg.max_agents > 0 { cfg.max_agents } else { lib.vulns.len() };
|
||||
let selected: Vec<Agent> = lib.vulns.iter().take(cap).cloned().collect();
|
||||
let _ = tx.send(format!("selected {} specialist agents", selected.len())).await;
|
||||
|
||||
if cfg.offline {
|
||||
let _ = tx.send("offline: no exploitation performed (provide API keys to run live)".into()).await;
|
||||
return RunOutput {
|
||||
findings: vec![],
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
|
||||
candidates: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 3. Exploit (parallel, bounded by the pool semaphore) ----------
|
||||
let target = cfg.target.clone();
|
||||
let candidates: Vec<Finding> = stream::iter(selected.iter().cloned())
|
||||
.map(|ag| {
|
||||
let target = target.clone();
|
||||
let recon = recon.clone();
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
let user = format!(
|
||||
"{}\n\nReply ONLY with a JSON array of confirmed findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
|
||||
ag.user.replace("{target}", &target).replace("{recon_json}", &recon)
|
||||
);
|
||||
match pool.complete(&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;
|
||||
f
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(cfg.concurrency)
|
||||
.collect::<Vec<Vec<Finding>>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
let _ = tx.send(format!("{} candidate finding(s) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
|
||||
|
||||
// ---- 4. Validate by N-model voting ---------------------------------
|
||||
let vote_n = cfg.vote_n;
|
||||
let validated: Vec<Finding> = stream::iter(candidates.into_iter())
|
||||
.map(|mut f| {
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
let q = format!(
|
||||
"Finding: {} | severity {} | {} | endpoint {} | payload {} | evidence {}",
|
||||
f.title, f.severity, f.cwe, f.endpoint, f.payload, f.evidence
|
||||
);
|
||||
let (yes, total) = pool.vote(VOTE_SYS, &q, vote_n).await;
|
||||
f.validated = total > 0 && yes * 2 >= total;
|
||||
f.votes = format!("{yes}/{total}");
|
||||
if f.confidence == 0.0 && total > 0 {
|
||||
f.confidence = yes as f64 / total as f64;
|
||||
}
|
||||
let _ = txc
|
||||
.send(format!("vote {} → {} ({})", f.title, if f.validated { "CONFIRMED" } else { "rejected" }, f.votes))
|
||||
.await;
|
||||
f
|
||||
}
|
||||
})
|
||||
.buffer_unordered(cfg.concurrency)
|
||||
.collect::<Vec<Finding>>()
|
||||
.await;
|
||||
|
||||
let candidates = validated.len();
|
||||
let findings: Vec<Finding> = validated.into_iter().filter(|f| f.validated).collect();
|
||||
let _ = tx.send(format!("{} validated finding(s)", findings.len())).await;
|
||||
|
||||
RunOutput {
|
||||
findings,
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull a JSON array (or object) of findings out of a model's reply.
|
||||
fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
|
||||
let slice = match (text.find('['), text.rfind(']')) {
|
||||
(Some(a), Some(b)) if b > a => &text[a..=b],
|
||||
_ => match (text.find('{'), text.rfind('}')) {
|
||||
(Some(a), Some(b)) if b > a => &text[a..=b],
|
||||
_ => return vec![],
|
||||
},
|
||||
};
|
||||
let mut out: Vec<Finding> = if let Ok(v) = serde_json::from_str::<Vec<Finding>>(slice) {
|
||||
v
|
||||
} else if let Ok(one) = serde_json::from_str::<Finding>(slice) {
|
||||
vec![one]
|
||||
} else {
|
||||
return vec![];
|
||||
};
|
||||
for f in out.iter_mut() {
|
||||
f.agent = agent.to_string();
|
||||
if f.id.is_empty() {
|
||||
f.id = format!("{}-{}", agent, &f.title.chars().take(12).collect::<String>());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::models::{ChatClient, ModelRef};
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// A pool of candidate models with a global concurrency cap and provider
|
||||
/// failover. The same panel of models is reused for validator voting.
|
||||
pub struct ModelPool {
|
||||
client: ChatClient,
|
||||
sem: Arc<Semaphore>,
|
||||
pub candidates: Vec<ModelRef>,
|
||||
}
|
||||
|
||||
impl ModelPool {
|
||||
pub fn new(models: Vec<ModelRef>, concurrency: usize) -> Self {
|
||||
let concurrency = concurrency.max(1);
|
||||
ModelPool {
|
||||
client: ChatClient::new(),
|
||||
sem: Arc::new(Semaphore::new(concurrency)),
|
||||
candidates: if models.is_empty() {
|
||||
vec![ModelRef::parse("anthropic:claude-opus-4-8")]
|
||||
} else {
|
||||
models
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let _permit = self.sem.acquire().await.expect("semaphore closed");
|
||||
let mut last = anyhow!("no candidate models");
|
||||
for m in &self.candidates {
|
||||
match self.client.chat(m, system, user).await {
|
||||
Ok(text) => return Ok((m.clone(), text)),
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// 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<ModelRef> = self.candidates.iter().take(n.max(1)).cloned().collect();
|
||||
let mut confirmed = 0usize;
|
||||
let mut total = 0usize;
|
||||
for m in &panel {
|
||||
let _permit = match self.sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
if let Ok(text) = self.client.chat(m, system, user).await {
|
||||
total += 1;
|
||||
let t = text.to_lowercase();
|
||||
if t.contains("\"verdict\": \"confirmed\"")
|
||||
|| t.trim_start().starts_with("yes")
|
||||
|| t.contains("confirmed: true")
|
||||
|| t.contains("is_real\": true")
|
||||
{
|
||||
confirmed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(confirmed, total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use crate::types::Finding;
|
||||
|
||||
fn sev_rank(s: &str) -> u8 {
|
||||
match s {
|
||||
"Critical" => 0,
|
||||
"High" => 1,
|
||||
"Medium" => 2,
|
||||
"Low" => 3,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn sev_color(s: &str) -> &'static str {
|
||||
match s {
|
||||
"Critical" => "#c0392b",
|
||||
"High" => "#e67e22",
|
||||
"Medium" => "#f1c40f",
|
||||
"Low" => "#3498db",
|
||||
_ => "#7f8c8d",
|
||||
}
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// Render an HTML report for the validated findings.
|
||||
pub fn html(target: &str, findings: &[Finding]) -> String {
|
||||
let mut sorted = findings.to_vec();
|
||||
sorted.sort_by_key(|f| sev_rank(&f.severity));
|
||||
|
||||
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
|
||||
for f in &sorted {
|
||||
*counts.entry(f.severity.as_str()).or_default() += 1;
|
||||
}
|
||||
let chips: String = if counts.is_empty() {
|
||||
"<span class=chip style=background:#27ae60>No validated findings</span>".into()
|
||||
} else {
|
||||
counts
|
||||
.iter()
|
||||
.map(|(s, n)| format!("<span class=chip style=background:{}>{}: {}</span>", sev_color(s), s, n))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let rows: String = sorted
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
format!(
|
||||
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}</h3>\
|
||||
<div class=m>{} · {} · CVSS {} · votes {} · conf {:.2}</div>\
|
||||
<div class=m>Endpoint: {}</div>\
|
||||
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>\
|
||||
<h4>Impact</h4><p>{}</p><h4>Remediation</h4><p>{}</p></section>",
|
||||
sev_color(&f.severity), esc(&f.severity), i + 1, esc(&f.title),
|
||||
esc(&f.agent), esc(&f.cwe), esc(&f.cvss), esc(&f.votes), f.confidence,
|
||||
esc(&f.endpoint), esc(&f.payload), esc(&f.evidence), esc(&f.impact), esc(&f.remediation),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let body = if rows.is_empty() {
|
||||
"<p><em>No validated findings were produced for this engagement.</em></p>".to_string()
|
||||
} else {
|
||||
rows
|
||||
};
|
||||
|
||||
format!(
|
||||
"<!DOCTYPE html><html><head><meta charset=utf-8><title>NeuroSploit Report — {t}</title><style>\
|
||||
body{{font:14px/1.6 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;max-width:860px;margin:40px auto;padding:0 24px}}\
|
||||
h1{{margin:0}}.meta{{color:#666;margin:4px 0 18px}}.chip{{color:#fff;border-radius:999px;padding:4px 12px;margin-right:8px;font-size:13px;font-weight:600}}\
|
||||
.finding{{border:1px solid #e3e3e3;border-radius:12px;padding:16px 20px;margin:16px 0}}.finding h3{{margin:0 0 8px;font-size:16px}}\
|
||||
.sev{{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}}.m{{color:#666;font-size:12px}}\
|
||||
pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}}\
|
||||
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>\
|
||||
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
|
||||
<div class=meta>Target: <b>{t}</b> · v3.4.0 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>",
|
||||
t = esc(target), chips = chips, n = sorted.len(), body = body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A validated (or candidate) security finding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Finding {
|
||||
pub id: String,
|
||||
pub agent: String,
|
||||
pub title: String,
|
||||
pub severity: String,
|
||||
#[serde(default)]
|
||||
pub cwe: String,
|
||||
#[serde(default)]
|
||||
pub cvss: String,
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
#[serde(default)]
|
||||
pub payload: String,
|
||||
#[serde(default)]
|
||||
pub evidence: String,
|
||||
#[serde(default)]
|
||||
pub impact: String,
|
||||
#[serde(default)]
|
||||
pub remediation: String,
|
||||
#[serde(default)]
|
||||
pub confidence: f64,
|
||||
#[serde(default)]
|
||||
pub validated: bool,
|
||||
/// Per-model vote summary, e.g. "3/4 confirmed".
|
||||
#[serde(default)]
|
||||
pub votes: String,
|
||||
}
|
||||
|
||||
impl Default for Finding {
|
||||
fn default() -> Self {
|
||||
Finding {
|
||||
id: String::new(),
|
||||
agent: String::new(),
|
||||
title: String::new(),
|
||||
severity: "Info".into(),
|
||||
cwe: String::new(),
|
||||
cvss: String::new(),
|
||||
endpoint: String::new(),
|
||||
payload: String::new(),
|
||||
evidence: String::new(),
|
||||
impact: String::new(),
|
||||
remediation: String::new(),
|
||||
confidence: 0.0,
|
||||
validated: false,
|
||||
votes: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a single engagement run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunConfig {
|
||||
pub target: String,
|
||||
/// Model references in `provider:model` form. The first is primary; the
|
||||
/// rest are failover candidates and also the voting panel.
|
||||
pub models: Vec<String>,
|
||||
/// Number of models that cross-check each candidate finding.
|
||||
#[serde(default = "default_vote")]
|
||||
pub vote_n: usize,
|
||||
/// Max concurrent model calls.
|
||||
#[serde(default = "default_concurrency")]
|
||||
pub concurrency: usize,
|
||||
/// Cap on specialist agents to run (0 = all).
|
||||
#[serde(default)]
|
||||
pub max_agents: usize,
|
||||
/// Offline mode: exercise the full pipeline without calling any model API.
|
||||
#[serde(default)]
|
||||
pub offline: bool,
|
||||
}
|
||||
|
||||
fn default_vote() -> usize {
|
||||
3
|
||||
}
|
||||
fn default_concurrency() -> usize {
|
||||
8
|
||||
}
|
||||
|
||||
impl RunConfig {
|
||||
pub fn new(target: impl Into<String>) -> Self {
|
||||
RunConfig {
|
||||
target: target.into(),
|
||||
models: vec!["anthropic:claude-opus-4-8".into()],
|
||||
vote_n: 3,
|
||||
concurrency: 8,
|
||||
max_agents: 0,
|
||||
offline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html><head><meta charset=utf-8><title>NeuroSploit Report — https://demo.testfire.net</title><style>body{font:14px/1.6 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;max-width:860px;margin:40px auto;padding:0 24px}h1{margin:0}.meta{color:#666;margin:4px 0 18px}.chip{color:#fff;border-radius:999px;padding:4px 12px;margin-right:8px;font-size:13px;font-weight:600}.finding{border:1px solid #e3e3e3;border-radius:12px;padding:16px 20px;margin:16px 0}.finding h3{margin:0 0 8px;font-size:16px}.sev{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}.m{color:#666;font-size:12px}pre{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}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><h1><span class=b>NeuroSploit</span> Penetration Test Report</h1><div class=meta>Target: <b>https://demo.testfire.net</b> · v3.4.0 Rust harness · multi-model validated</div><div><span class=chip style=background:#27ae60>No validated findings</span></div><h2>Findings (0)</h2><p><em>No validated findings were produced for this engagement.</em></p><p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.</p></body></html>
|
||||
Reference in New Issue
Block a user