mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-07-09 20:58:38 +02:00
v3.4.x: intelligent agent selection, whitebox, recon/code agents, Gemini, artifacts, RL, XBOW GUI
Harness intelligence: - After recon, the model SELECTS which specialist agents match the target (select_agents) — runs the relevant subset, not blindly top-N - RL reward store (rl.rs): per-agent weights persist to data/rl_state_rs.json, reward validated findings (severity-weighted), decay idle, bias next run - Run artifacts persisted as JSON + MD (recon, exploitation transcript, findings, html report) under runs/<target>-<ts>/ for reuse by other AIs Whitebox mode: - run_whitebox: walks a repo, builds bounded source context, runs code agents, validates by adversarial vote. CLI `whitebox <path>` + web "White-box" mode Agents: +12 recon (subdomain/tech/js/api/secrets/dns/content/param/waf/cloud/ graphql/osint) and +24 code SAST reviewers (sqli/cmdi/path/ssrf/xss/deser/ secrets/crypto/authz/idor/xxe/redirect/ssti/race/eval/csrf/random/logging/ upload/mass-assign/jwt/cors). Loader gains recon/ + code/ categories → 249 total Models: +Google Gemini provider (API + gemini CLI subscription); installed_cli_ backends now detects gemini; chat_cli handles gemini/codex/grok + optional Playwright MCP (.mcp.json) on the subscription path with autonomy flags GUI: full XBOW-style redesign — sidebar (Operate/Library), topbar status, mode segment (black-box/white-box), model panel, live console, severity cards, agent browser with category filters, models view; responsive + aligned Verified: cargo build --release clean; CLI agents/whitebox; LIVE subscription run shows model selecting 23→4 agents, RL update, artifacts written; GUI + white-box toggle in Playwright. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,20 +21,24 @@ pub struct Agent {
|
||||
pub struct Library {
|
||||
pub vulns: Vec<Agent>,
|
||||
pub meta: Vec<Agent>,
|
||||
pub recon: Vec<Agent>,
|
||||
pub code: Vec<Agent>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn total(&self) -> usize {
|
||||
self.vulns.len() + self.meta.len()
|
||||
self.vulns.len() + self.meta.len() + self.recon.len() + self.code.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `<base>/agents_md/{vulns,meta}/*.md`.
|
||||
/// Load `<base>/agents_md/{vulns,meta,recon,code}/*.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"),
|
||||
recon: load_dir(&root.join("recon"), "recon"),
|
||||
code: load_dir(&root.join("code"), "code"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ pub mod models;
|
||||
pub mod pipeline;
|
||||
pub mod pool;
|
||||
pub mod report;
|
||||
pub mod rl;
|
||||
pub mod types;
|
||||
|
||||
pub use agents::{Agent, Library};
|
||||
pub use models::{
|
||||
cli_binary_for, installed_cli_backends, provider_for, providers, ChatClient, ModelRef, Provider,
|
||||
cli_binary_for, installed_cli_backends, provider_for, providers, write_mcp_config, ChatClient,
|
||||
ModelRef, Provider,
|
||||
};
|
||||
pub use pipeline::{run_whitebox, RunOutput};
|
||||
pub use pipeline::run;
|
||||
pub use pool::ModelPool;
|
||||
pub use types::{Finding, RunConfig};
|
||||
|
||||
@@ -28,6 +28,8 @@ pub fn providers() -> Vec<Provider> {
|
||||
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: "gemini", label: "Google Gemini", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", env_key: "GEMINI_API_KEY", kind: "cli",
|
||||
models: vec!["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"] },
|
||||
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",
|
||||
@@ -124,9 +126,20 @@ impl ChatClient {
|
||||
|
||||
impl ChatClient {
|
||||
/// Complete via a locally-installed **agentic CLI subscription** (Claude
|
||||
/// Code / Codex / Grok) instead of an API key. This uses the user's logged-in
|
||||
/// subscription, so no provider key is required.
|
||||
pub async fn chat_cli(&self, provider: &str, model: &str, system: &str, user: &str) -> Result<String> {
|
||||
/// Code / Codex / Grok / Gemini) instead of an API key. This uses the user's
|
||||
/// logged-in subscription, so no provider key is required.
|
||||
///
|
||||
/// When `mcp_config` is set (a path to an `.mcp.json`), Claude/Codex run with
|
||||
/// the MCP servers enabled and tool autonomy, so agents can actually drive
|
||||
/// **Playwright** (browse, execute JS, screenshot) during execution.
|
||||
pub async fn chat_cli(
|
||||
&self,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
system: &str,
|
||||
user: &str,
|
||||
mcp_config: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let bin = cli_binary_for(provider)
|
||||
.ok_or_else(|| anyhow!("no CLI/subscription backend for provider '{}'", provider))?;
|
||||
let prompt = format!("{system}\n\n{user}");
|
||||
@@ -135,10 +148,24 @@ impl ChatClient {
|
||||
// Claude Code headless print mode (uses the Claude subscription login).
|
||||
"claude" => {
|
||||
cmd.arg("-p").arg("--model").arg(model);
|
||||
if let Some(mcp) = mcp_config {
|
||||
cmd.arg("--mcp-config").arg(mcp).arg("--dangerously-skip-permissions");
|
||||
// Required to allow tool autonomy when running as root.
|
||||
cmd.env("IS_SANDBOX", "1");
|
||||
}
|
||||
}
|
||||
// Codex non-interactive exec (uses the ChatGPT/Codex login), prompt on stdin.
|
||||
"codex" => {
|
||||
cmd.arg("exec").arg("--model").arg(model).arg("-");
|
||||
cmd.arg("exec").arg("--model").arg(model);
|
||||
if let Some(mcp) = mcp_config {
|
||||
cmd.arg("--config").arg(format!("mcp_config_file={mcp}"))
|
||||
.arg("--dangerously-bypass-approvals-and-sandbox");
|
||||
}
|
||||
cmd.arg("-");
|
||||
}
|
||||
// Google Gemini CLI (uses the Gemini subscription login).
|
||||
"gemini" => {
|
||||
cmd.arg("-m").arg(model);
|
||||
}
|
||||
// Grok CLI, prompt on stdin (best-effort flags).
|
||||
"grok" => {
|
||||
@@ -166,6 +193,7 @@ pub fn cli_binary_for(provider: &str) -> Option<&'static str> {
|
||||
"anthropic" => Some("claude"),
|
||||
"openai" => Some("codex"),
|
||||
"xai" => Some("grok"),
|
||||
"gemini" => Some("gemini"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -179,7 +207,21 @@ pub fn binary_in_path(name: &str) -> bool {
|
||||
|
||||
/// Which subscription CLI backends are installed locally.
|
||||
pub fn installed_cli_backends() -> Vec<&'static str> {
|
||||
["claude", "codex", "grok"].into_iter().filter(|b| binary_in_path(b)).collect()
|
||||
["claude", "codex", "grok", "gemini"].into_iter().filter(|b| binary_in_path(b)).collect()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
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)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Default for ChatClient {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::agents::{Agent, Library};
|
||||
use crate::pool::ModelPool;
|
||||
use crate::rl::{severity_reward, RlState};
|
||||
use crate::types::{Finding, RunConfig};
|
||||
use crate::report;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
/// Result of an engagement run.
|
||||
@@ -11,26 +14,28 @@ pub struct RunOutput {
|
||||
pub findings: Vec<Finding>,
|
||||
pub agents_ran: Vec<String>,
|
||||
pub candidates: usize,
|
||||
pub recon: String,
|
||||
/// Paths to persisted artifacts (recon/exploit/findings/report), if any.
|
||||
pub artifacts: Vec<String>,
|
||||
}
|
||||
|
||||
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.";
|
||||
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\":\"...\"}.";
|
||||
|
||||
/// Run the full harness pipeline, streaming human-readable progress over `tx`.
|
||||
/// 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
|
||||
.send(format!(
|
||||
"Loaded {} agents ({} vuln / {} meta) · models: {} · vote_n={} · concurrency={}",
|
||||
lib.total(),
|
||||
lib.vulns.len(),
|
||||
lib.meta.len(),
|
||||
"Loaded {} agents ({} vuln / {} recon / {} code / {} meta) · models: {} · vote_n={} · concurrency={}{}",
|
||||
lib.total(), lib.vulns.len(), lib.recon.len(), lib.code.len(), lib.meta.len(),
|
||||
pool.candidates.iter().map(|m| m.label()).collect::<Vec<_>>().join(", "),
|
||||
cfg.vote_n,
|
||||
cfg.concurrency,
|
||||
cfg.vote_n, cfg.concurrency,
|
||||
if pool.mcp_config.is_some() { " · Playwright MCP ON" } else { "" },
|
||||
))
|
||||
.await;
|
||||
|
||||
// ---- 1. Recon -------------------------------------------------------
|
||||
// ---- 1. Recon ------------------------------------------------------
|
||||
let recon = if cfg.offline {
|
||||
let _ = tx.send("recon: offline mode — skipping model calls".into()).await;
|
||||
"{}".to_string()
|
||||
@@ -47,23 +52,42 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 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;
|
||||
// ---- 2. Intelligent, RL-ranked agent selection ---------------------
|
||||
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
|
||||
let mut ranked: Vec<Agent> = lib.vulns.clone();
|
||||
ranked.sort_by(|a, b| rl.weight(&b.name).partial_cmp(&rl.weight(&a.name)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(ranked.len()) } else { ranked.len() };
|
||||
|
||||
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,
|
||||
};
|
||||
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
|
||||
let _ = tx.send(format!("selected {} specialist agents (RL-ranked)", selected.len())).await;
|
||||
let _ = tx.send("offline: no exploitation performed (provide API keys or --subscription to run live)".into()).await;
|
||||
let artifacts = persist(&cfg, &recon, "", &[]);
|
||||
return RunOutput { findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
}
|
||||
|
||||
// ---- 3. Exploit (parallel, bounded by the pool semaphore) ----------
|
||||
// Use the model to pick the agents whose preconditions match the recon —
|
||||
// the harness reasons about *which* specialists to run, not all of them.
|
||||
let chosen = select_agents(pool, &recon, &ranked, &tx).await;
|
||||
let selected: Vec<Agent> = {
|
||||
let mut sel: Vec<Agent> = if chosen.is_empty() {
|
||||
ranked.clone()
|
||||
} else {
|
||||
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect()
|
||||
};
|
||||
if sel.is_empty() {
|
||||
sel = ranked.clone();
|
||||
}
|
||||
sel.into_iter().take(cap).collect()
|
||||
};
|
||||
let _ = tx
|
||||
.send(format!("intelligently selected {} agent(s) matching recon: {}", selected.len(),
|
||||
selected.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", ")))
|
||||
.await;
|
||||
|
||||
// ---- 3. Exploit (parallel) -----------------------------------------
|
||||
let target = cfg.target.clone();
|
||||
let candidates: Vec<Finding> = stream::iter(selected.iter().cloned())
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
.map(|ag| {
|
||||
let target = target.clone();
|
||||
let recon = recon.clone();
|
||||
@@ -77,64 +101,221 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
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
|
||||
let _ = txc.send(format!("exploit {} via {} → {} candidate(s)", ag.name, m.label(), f.len())).await;
|
||||
(ag.name.clone(), text, f)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
|
||||
vec![]
|
||||
(ag.name.clone(), format!("ERROR: {e}"), vec![])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(cfg.concurrency)
|
||||
.collect::<Vec<Vec<Finding>>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
.collect()
|
||||
.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;
|
||||
|
||||
// ---- 4. Validate by N-model voting ---------------------------------
|
||||
let vote_n = cfg.vote_n;
|
||||
let findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
|
||||
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
||||
}
|
||||
|
||||
/// White-box engagement: analyse a repository's source for vulnerabilities.
|
||||
pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||
let _ = tx.send(format!("WHITEBOX · repo: {} · {} code agents · models: {}", cfg.target, lib.code.len(),
|
||||
pool.candidates.iter().map(|m| m.label()).collect::<Vec<_>>().join(", "))).await;
|
||||
|
||||
let context = collect_repo_context(Path::new(&cfg.target), 200, 120_000);
|
||||
let bytes = context.len();
|
||||
let _ = tx.send(format!("collected {} bytes of source context", bytes)).await;
|
||||
if bytes == 0 {
|
||||
let _ = tx.send("no readable source found at the given path".into()).await;
|
||||
}
|
||||
|
||||
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
|
||||
let mut ranked: Vec<Agent> = if lib.code.is_empty() { lib.vulns.clone() } else { lib.code.clone() };
|
||||
ranked.sort_by(|a, b| rl.weight(&b.name).partial_cmp(&rl.weight(&a.name)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(ranked.len()) } else { ranked.len() };
|
||||
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
|
||||
let _ = tx.send(format!("selected {} code-analysis agents", selected.len())).await;
|
||||
|
||||
if cfg.offline || bytes == 0 {
|
||||
let artifacts = persist(&cfg, "{}", &context, &[]);
|
||||
return RunOutput { findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts };
|
||||
}
|
||||
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
.map(|ag| {
|
||||
let ctx = context.clone();
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
let user = format!(
|
||||
"{}\n\nSOURCE CODE TO REVIEW:\n```\n{}\n```\n\nReply ONLY with a JSON array of findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}} \
|
||||
where `endpoint` is the file:line and `evidence` quotes the vulnerable code.",
|
||||
ag.user.replace("{target}", "the provided repository").replace("{recon_json}", "{}"),
|
||||
ctx
|
||||
);
|
||||
match pool.complete(&ag.system, &user).await {
|
||||
Ok((m, text)) => {
|
||||
let f = extract_findings(&text, &ag.name);
|
||||
let _ = txc.send(format!("analyze {} via {} → {} candidate(s)", ag.name, m.label(), f.len())).await;
|
||||
(ag.name.clone(), text, f)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = txc.send(format!("analyze {} failed: {e}", ag.name)).await;
|
||||
(ag.name.clone(), format!("ERROR: {e}"), vec![])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(cfg.concurrency)
|
||||
.collect()
|
||||
.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 findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
|
||||
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- shared
|
||||
|
||||
const SELECT_SYS: &str = "You are a penetration-test orchestrator. Given recon of a target and a catalog of specialist agents, choose ONLY the agents whose preconditions clearly match the target's attack surface. Be selective. Reply with a JSON array of agent names (strings) drawn exactly from the catalog. No prose.";
|
||||
|
||||
/// Ask the model which agents to run for this recon. Returns chosen agent names
|
||||
/// (empty on failure → caller falls back to RL-ranked agents).
|
||||
async fn select_agents(pool: &ModelPool, recon: &str, catalog: &[Agent], tx: &Sender<String>) -> Vec<String> {
|
||||
let list = catalog
|
||||
.iter()
|
||||
.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 {
|
||||
Ok((m, text)) => {
|
||||
let names = parse_string_array(&text);
|
||||
let _ = tx.send(format!("agent selection via {} → {} agent(s) chosen", m.label(), names.len())).await;
|
||||
names
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(format!("agent selection failed ({e}) — falling back to RL ranking")).await;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_array(text: &str) -> Vec<String> {
|
||||
match (text.find('['), text.rfind(']')) {
|
||||
(Some(a), Some(b)) if b > a => serde_json::from_str::<Vec<String>>(&text[a..=b]).unwrap_or_default(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n: usize, tx: &Sender<String>) -> Vec<Finding> {
|
||||
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 {}",
|
||||
"Finding: {} | severity {} | {} | at {} | 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;
|
||||
let (yes, total) = pool.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;
|
||||
let _ = txc.send(format!("vote {} → {} ({})", f.title, if f.validated { "CONFIRMED" } else { "rejected" }, f.votes)).await;
|
||||
f
|
||||
}
|
||||
})
|
||||
.buffer_unordered(cfg.concurrency)
|
||||
.collect::<Vec<Finding>>()
|
||||
.buffer_unordered(pool.candidates.len().max(2))
|
||||
.collect()
|
||||
.await;
|
||||
validated.into_iter().filter(|f| f.validated).collect()
|
||||
}
|
||||
|
||||
let candidates = validated.len();
|
||||
let findings: Vec<Finding> = validated.into_iter().filter(|f| f.validated).collect();
|
||||
async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, findings: Vec<Finding>,
|
||||
selected: Vec<Agent>, rl: &mut RlState, tx: Sender<String>) -> RunOutput {
|
||||
let _ = tx.send(format!("{} validated finding(s)", findings.len())).await;
|
||||
|
||||
// RL update: reward agents that produced validated findings; gently decay idle.
|
||||
let hit: std::collections::HashMap<&str, f64> = findings.iter().fold(Default::default(), |mut m, f| {
|
||||
let e = m.entry(f.agent.as_str()).or_insert(0.0);
|
||||
*e = (*e + severity_reward(&f.severity)).min(1.0);
|
||||
m
|
||||
});
|
||||
for a in &selected {
|
||||
let r = hit.get(a.name.as_str()).copied().unwrap_or(-0.05);
|
||||
rl.update(&a.name, r);
|
||||
}
|
||||
rl.runs += 1;
|
||||
if let Some(p) = &cfg.rl_path {
|
||||
rl.save(Path::new(p));
|
||||
let _ = tx.send("RL rewards updated".into()).await;
|
||||
}
|
||||
|
||||
let artifacts = persist(&cfg, &recon, &transcript, &findings);
|
||||
if !artifacts.is_empty() {
|
||||
let _ = tx.send(format!("artifacts saved: {}", artifacts.join(", "))).await;
|
||||
}
|
||||
|
||||
RunOutput {
|
||||
candidates: findings.len(),
|
||||
findings,
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
|
||||
candidates,
|
||||
recon,
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write recon/exploit/findings/report as json+md for downstream reuse.
|
||||
fn persist(cfg: &RunConfig, recon: &str, transcript: &str, findings: &[Finding]) -> Vec<String> {
|
||||
let Some(dir) = &cfg.workdir else { return vec![] };
|
||||
let dir = PathBuf::from(dir);
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return vec![];
|
||||
}
|
||||
let mut written = Vec::new();
|
||||
let mut put = |name: &str, content: String| {
|
||||
let p = dir.join(name);
|
||||
if std::fs::write(&p, content).is_ok() {
|
||||
written.push(p.display().to_string());
|
||||
}
|
||||
};
|
||||
put("recon.json", recon.to_string());
|
||||
put("recon.md", format!("# Recon — {}\n\n```json\n{}\n```\n", cfg.target, recon));
|
||||
if !transcript.is_empty() {
|
||||
put("exploitation.md", format!("# Agent transcript — {}\n\n{}", cfg.target, transcript));
|
||||
}
|
||||
put("findings.json", serde_json::to_string_pretty(findings).unwrap_or_else(|_| "[]".into()));
|
||||
put("findings.md", findings_md(&cfg.target, findings));
|
||||
put("report.html", report::html(&cfg.target, findings));
|
||||
written
|
||||
}
|
||||
|
||||
fn findings_md(target: &str, findings: &[Finding]) -> String {
|
||||
let mut s = format!("# NeuroSploit findings — {}\n\n{} validated finding(s).\n", target, findings.len());
|
||||
for (i, f) in findings.iter().enumerate() {
|
||||
s.push_str(&format!(
|
||||
"\n## {}. [{}] {}\n- agent: `{}` CWE: {} CVSS: {} votes: {} confidence: {:.2}\n- endpoint: {}\n\n**Payload**\n```\n{}\n```\n\n**Evidence**\n{}\n\n**Impact:** {}\n\n**Remediation:** {}\n",
|
||||
i + 1, f.severity, f.title, f.agent, f.cwe, f.cvss, f.votes, f.confidence, f.endpoint, f.payload, f.evidence, f.impact, f.remediation
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn transcript_of(raw: &[(String, String, Vec<Finding>)]) -> String {
|
||||
raw.iter().map(|(n, t, f)| format!("## {} ({} candidate)\n\n{}\n", n, f.len(), t)).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// 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(']')) {
|
||||
@@ -154,7 +335,42 @@ fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
|
||||
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>());
|
||||
f.id = format!("{}-{}", agent, f.title.chars().take(12).collect::<String>());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Concatenate source files under `root` into a bounded review context.
|
||||
fn collect_repo_context(root: &Path, max_files: usize, max_bytes: usize) -> String {
|
||||
const EXTS: &[&str] = &[
|
||||
"rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "php", "rb", "c", "cc", "cpp", "h", "hpp",
|
||||
"cs", "kt", "swift", "scala", "sh", "sql", "html", "vue", "yml", "yaml", "tf",
|
||||
];
|
||||
let mut out = String::new();
|
||||
let mut files = 0usize;
|
||||
if !root.exists() {
|
||||
return out;
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(root).max_depth(8).into_iter().flatten() {
|
||||
if files >= max_files || out.len() >= max_bytes {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let s = path.to_string_lossy();
|
||||
if s.contains("/.git/") || s.contains("/node_modules/") || s.contains("/target/") || s.contains("/vendor/") {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !EXTS.contains(&ext) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let budget = max_bytes.saturating_sub(out.len());
|
||||
let take = content.len().min(budget).min(8_000);
|
||||
out.push_str(&format!("\n// ===== file: {} =====\n{}\n", rel, &content[..take]));
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -13,14 +13,21 @@ pub struct ModelPool {
|
||||
sem: Arc<Semaphore>,
|
||||
pub candidates: Vec<ModelRef>,
|
||||
pub subscription: bool,
|
||||
/// Path to an `.mcp.json` (Playwright) used on the subscription/CLI path.
|
||||
pub mcp_config: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelPool {
|
||||
pub fn new(models: Vec<ModelRef>, concurrency: usize) -> Self {
|
||||
Self::with_auth(models, concurrency, false)
|
||||
Self::with_auth(models, concurrency, false, None)
|
||||
}
|
||||
|
||||
pub fn with_auth(models: Vec<ModelRef>, concurrency: usize, subscription: bool) -> Self {
|
||||
pub fn with_auth(
|
||||
models: Vec<ModelRef>,
|
||||
concurrency: usize,
|
||||
subscription: bool,
|
||||
mcp_config: Option<String>,
|
||||
) -> Self {
|
||||
let concurrency = concurrency.max(1);
|
||||
ModelPool {
|
||||
client: ChatClient::new(),
|
||||
@@ -31,13 +38,17 @@ impl ModelPool {
|
||||
models
|
||||
},
|
||||
subscription,
|
||||
mcp_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// One completion for a model, via subscription CLI or HTTP API.
|
||||
/// One completion for a model, via subscription CLI (optionally with MCP) or HTTP API.
|
||||
async fn one(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
|
||||
if self.subscription && cli_binary_for(&m.provider).is_some() {
|
||||
return self.client.chat_cli(&m.provider, &m.model, system, user).await;
|
||||
return self
|
||||
.client
|
||||
.chat_cli(&m.provider, &m.model, system, user, self.mcp_config.as_deref())
|
||||
.await;
|
||||
}
|
||||
self.client.chat(m, system, user).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Lightweight reinforcement-learning reward store for the harness.
|
||||
//!
|
||||
//! Each agent carries a weight in [0.05, 1.0]; validated findings reward it,
|
||||
//! idle runs decay it slightly. Weights bias agent ordering on future runs and
|
||||
//! persist to a JSON file so the harness gets sharper over time.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct RlState {
|
||||
#[serde(default)]
|
||||
pub weights: HashMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub runs: u64,
|
||||
}
|
||||
|
||||
const ALPHA: f64 = 0.3;
|
||||
const WMIN: f64 = 0.05;
|
||||
const WMAX: f64 = 1.0;
|
||||
|
||||
impl RlState {
|
||||
pub fn load(path: &Path) -> RlState {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn weight(&self, agent: &str) -> f64 {
|
||||
*self.weights.get(agent).unwrap_or(&0.5)
|
||||
}
|
||||
|
||||
/// Reward in [-1, 1]; e.g. severity-weighted hits positive, idle negative.
|
||||
pub fn update(&mut self, agent: &str, reward: f64) {
|
||||
let w = self.weights.entry(agent.to_string()).or_insert(0.5);
|
||||
*w = (*w + ALPHA * (reward - *w)).clamp(WMIN, WMAX);
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(s) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(path, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Severity → reward weight.
|
||||
pub fn severity_reward(sev: &str) -> f64 {
|
||||
match sev {
|
||||
"Critical" => 1.0,
|
||||
"High" => 0.7,
|
||||
"Medium" => 0.4,
|
||||
"Low" => 0.2,
|
||||
_ => 0.05,
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,12 @@ pub struct RunConfig {
|
||||
/// of HTTP API keys.
|
||||
#[serde(default)]
|
||||
pub subscription: bool,
|
||||
/// Directory to persist run artifacts (recon/exploit/findings json+md).
|
||||
#[serde(default)]
|
||||
pub workdir: Option<String>,
|
||||
/// Path to the RL reward state file.
|
||||
#[serde(default)]
|
||||
pub rl_path: Option<String>,
|
||||
}
|
||||
|
||||
fn default_vote() -> usize {
|
||||
@@ -93,6 +99,8 @@ impl RunConfig {
|
||||
max_agents: 0,
|
||||
offline: false,
|
||||
subscription: false,
|
||||
workdir: None,
|
||||
rl_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user