v3.5.1: attack-chain agents (12) + per-project .neurosploit/ persistence & resume

Chaining:
- agents_md/chains/ (12 multi-stage exploitation playbooks): SQLi→RCE→LPE,
  SSRF→AWS-creds, SSRF→RCE, upload→RCE, upload→LFI→RCE→LPE, XSS→ATO, IDOR→ATO,
  SSTI→RCE→cloud, default-creds→domain, deserialization→RCE, exposed-git→RCE,
  subdomain-takeover→trusted-abuse. Each stage proven by a tool receipt before
  advancing; reports chains_from edges.
- Loaded as a `chains` category (→ 329 agents). chain_round now injects the chain
  recipes as a menu so the LLM applies proven multi-stage paths.

Persistence (no DB — structured state):
- Per-project `<cwd>/.neurosploit/` holding session.json (config), runs.json
  (history), history.txt (readline). REPL resumes target/repo/auth/focus/models
  on reopen; saves on /run and /quit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 22:30:22 -03:00
co-authored by Claude Opus 4.8
parent f8d70ce9c5
commit 639c2209f7
17 changed files with 785 additions and 23 deletions
+2 -2
View File
@@ -201,8 +201,8 @@ async fn main() -> anyhow::Result<()> {
Cmd::Agents => {
let lib = agents::load(&base);
println!(
"{{\"vulns\":{},\"recon\":{},\"code\":{},\"infra\":{},\"meta\":{},\"total\":{}}}",
lib.vulns.len(), lib.recon.len(), lib.code.len(), lib.infra.len(), lib.meta.len(), lib.total()
"{{\"vulns\":{},\"recon\":{},\"code\":{},\"infra\":{},\"chains\":{},\"meta\":{},\"total\":{}}}",
lib.vulns.len(), lib.recon.len(), lib.code.len(), lib.infra.len(), lib.chains.len(), lib.meta.len(), lib.total()
);
}
Cmd::Models => {
+56 -14
View File
@@ -139,13 +139,12 @@ enum Reader {
}
impl Reader {
fn new(base: &Path) -> Reader {
fn new(_base: &Path) -> Reader {
if std::io::stdin().is_terminal() {
let cfg = Config::builder().auto_add_history(false).build();
if let Ok(mut ed) = Editor::<NsHelper, FileHistory>::with_config(cfg) {
ed.set_helper(Some(NsHelper));
let hist = base.join("data").join("repl_history.txt");
std::fs::create_dir_all(hist.parent().unwrap()).ok();
let hist = proj_dir().join("history.txt");
let _ = ed.load_history(&hist);
return Reader::Rl(Box::new(ed), hist);
}
@@ -200,9 +199,11 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
println!(" Type \x1b[36m/help\x1b[0m to start, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit. (↑/↓ recalls commands)\n");
let mut s = Session::default();
let resumed = load_session(&mut s);
let mut history: Vec<RunRecord> = load_runs(base);
if !history.is_empty() {
println!(" loaded {} past run(s) — /runs to list\n", history.len());
if resumed || !history.is_empty() {
println!(" ↻ resumed project session from {}{} past run(s)\n",
proj_dir().display(), history.len());
}
let mut reader = Reader::new(base);
show(&s);
@@ -281,12 +282,12 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
"/agents" => { s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {}", s.max_agents); }
"/clear" => { print!("\x1b[2J\x1b[H"); }
"/run" | "/go" => { run(base, &s, &mut history).await; save_runs(base, &history); }
"/run" | "/go" => { save_session(&s); run(base, &s, &mut history).await; save_runs(base, &history); }
"/runs" | "/history" => list_runs(&history),
"/results" => results(&history, arg),
"/report" => open_report(&history, arg),
"/status" => run_status(&history, arg),
"/quit" | "/exit" | "/q" => { println!(" bye."); break; }
"/quit" | "/exit" | "/q" => { save_session(&s); println!(" session saved → {} · bye.", proj_dir().display()); break; }
other => println!(" unknown command '{other}' — try /help"),
}
}
@@ -423,20 +424,61 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
}
}
fn runs_path(base: &Path) -> std::path::PathBuf {
base.join("data").join("repl_runs.json")
/// Project-local store: `<cwd>/.neurosploit/` so each project keeps its own
/// session, run history and command history (resume on reopen). No DB needed —
/// it's structured state, not semantic search.
pub(crate) fn proj_dir() -> std::path::PathBuf {
let d = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")).join(".neurosploit");
std::fs::create_dir_all(&d).ok();
d
}
fn load_runs(base: &Path) -> Vec<RunRecord> {
std::fs::read_to_string(runs_path(base)).ok()
fn runs_path(_base: &Path) -> std::path::PathBuf { proj_dir().join("runs.json") }
fn load_runs(_base: &Path) -> Vec<RunRecord> {
std::fs::read_to_string(runs_path(_base)).ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn save_runs(base: &Path, history: &[RunRecord]) {
let p = runs_path(base);
if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).ok(); }
fn save_runs(_base: &Path, history: &[RunRecord]) {
let p = runs_path(_base);
if let Ok(j) = serde_json::to_string_pretty(history) { std::fs::write(p, j).ok(); }
}
/// Persistable snapshot of the session config (resume across restarts).
#[derive(Serialize, Deserialize, Default)]
struct Snapshot {
models: Vec<String>,
subscription: bool,
mcp: bool,
vote_n: usize,
max_agents: usize,
target: Option<String>,
repo: Option<String>,
auth: Option<String>,
creds: Option<String>,
instructions: Option<String>,
}
fn session_path() -> std::path::PathBuf { proj_dir().join("session.json") }
fn save_session(s: &Session) {
let snap = Snapshot {
models: s.models.clone(), subscription: s.subscription, mcp: s.mcp,
vote_n: s.vote_n, max_agents: s.max_agents, target: s.target.clone(),
repo: s.repo.clone(), auth: s.auth.clone(), creds: s.creds.clone(),
instructions: s.instructions.clone(),
};
if let Ok(j) = serde_json::to_string_pretty(&snap) { std::fs::write(session_path(), j).ok(); }
}
fn load_session(s: &mut Session) -> bool {
let Ok(txt) = std::fs::read_to_string(session_path()) else { return false };
let Ok(snap) = serde_json::from_str::<Snapshot>(&txt) else { return false };
if !snap.models.is_empty() { s.models = snap.models; }
s.subscription = snap.subscription; s.mcp = snap.mcp;
if snap.vote_n > 0 { s.vote_n = snap.vote_n; }
s.max_agents = snap.max_agents;
s.target = snap.target; s.repo = snap.repo; s.auth = snap.auth;
s.creds = snap.creds; s.instructions = snap.instructions;
true
}
fn pick<'a>(history: &'a [RunRecord], arg: &str) -> Option<&'a RunRecord> {
if history.is_empty() { println!(" no runs yet — /run first."); return None; }
if arg.trim().is_empty() { return history.last(); }
+4 -1
View File
@@ -24,11 +24,13 @@ pub struct Library {
pub recon: Vec<Agent>,
pub code: Vec<Agent>,
pub infra: Vec<Agent>,
pub chains: Vec<Agent>,
}
impl Library {
pub fn total(&self) -> usize {
self.vulns.len() + self.meta.len() + self.recon.len() + self.code.len() + self.infra.len()
self.vulns.len() + self.meta.len() + self.recon.len() + self.code.len()
+ self.infra.len() + self.chains.len()
}
}
@@ -41,6 +43,7 @@ pub fn load(base: &Path) -> Library {
recon: load_dir(&root.join("recon"), "recon"),
code: load_dir(&root.join("code"), "code"),
infra: load_dir(&root.join("infra"), "infra"),
chains: load_dir(&root.join("chains"), "chain"),
}
}
+10 -6
View File
@@ -206,7 +206,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
// ---- 5. Chain confirmed findings into deeper impact ----------------
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &tx).await;
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &lib.chains, &tx).await;
if !chained.is_empty() {
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
let _ = tx.send(format!("chaining added {} validated finding(s)", extra.len())).await;
@@ -409,7 +409,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &tx).await;
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &lib.chains, &tx).await;
if !chained.is_empty() {
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
let _ = tx.send(format!("chaining added {} validated finding(s)", extra.len())).await;
@@ -425,19 +425,23 @@ const CHAIN_SYS: &str = "You are an exploit-chaining specialist. Given already-C
/// into higher-impact follow-ups, reusing the recon/auth context. Returns the
/// (unvalidated) new candidate findings produced by chaining.
async fn chain_round(pool: &ModelPool, target: &str, recon: &str, directives: &str,
confirmed: &[Finding], tx: &Sender<String>) -> Vec<Finding> {
confirmed: &[Finding], chains: &[Agent], tx: &Sender<String>) -> Vec<Finding> {
if confirmed.is_empty() {
return vec![];
}
let summary: String = confirmed.iter().take(20)
.map(|f| format!("- [{}] {} @ {} ({})", f.severity, f.title, f.endpoint, f.cwe))
.collect::<Vec<_>>().join("\n");
// Offer the known chain recipes as a menu so the LLM applies proven multi-stage paths.
let recipes: String = chains.iter().map(|a| format!("- {}", a.title.replace(" Agent", ""))).collect::<Vec<_>>().join("\n");
let recipe_block = if recipes.is_empty() { String::new() } else { format!("KNOWN CHAIN RECIPES (apply any that fit):\n{recipes}\n\n") };
let _ = tx.send(format!("chaining {} confirmed finding(s) for deeper impact…", confirmed.len())).await;
let recon_ctx: String = recon.chars().take(2500).collect();
let user = format!(
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{doctrine}\
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{doctrine}{recipe_block}\
CONFIRMED FINDINGS TO CHAIN:\n{summary}\n\nRecon:\n{recon_ctx}\n\n\
Chain these into deeper impact and PROVE it. Reply ONLY a JSON array of NEW findings \
Chain these into deeper impact (e.g. SQLi→RCE→LPE, SSRF→cloud creds, upload→LFI→RCE) and PROVE each stage. \
Reply ONLY a JSON array of NEW findings \
(may be []): {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
react = REACT_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()),
);
@@ -951,7 +955,7 @@ pub async fn run_host(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sende
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &tx).await;
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &lib.chains, &tx).await;
if !chained.is_empty() {
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
findings.extend(extra);