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(); }