mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 21:19:49 +02:00
NeuroSploit v3.4.0 — Rust multi-model harness + Axum dashboard
New cargo workspace `neurosploit-rs/` (single `neurosploit` binary): harness crate: - models.rs: 11 OpenAI-compatible providers / 31 models (Claude, GPT, Grok, NVIDIA NIM, DeepSeek, Mistral, Qwen, Groq, Together, OpenRouter, Ollama) - pool.rs: ModelPool with bounded concurrency, provider failover, and N-model validator voting (the panel doubles as the jury) - agents.rs: loads the existing agents_md/ library (213 agents) - pipeline.rs: recon → parallel exploit (semaphore-bounded) → N-model adversarial vote → score; streams live progress over a channel - report.rs: HTML report - tokio + reqwest(rustls); offline mode runs the pipeline without API keys app binary: - clap CLI: serve | run | agents | models (run supports --model x N, --vote-n, --max-agents, --offline) - axum web dashboard with multi-model panel, live console, findings, agent browser, embedded report; single binary serves the SPA (no npm/build) Verified: cargo build clean; agents/models/offline-run CLI; server endpoints (/api/info, /api/run lifecycle, /report); dashboard + live run in Playwright. Docs: README v3.4.0 callout + RELEASE.md notes. target/ gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5badefc29
commit
56d3f0c723
18 files changed
+3268
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user