From e1c1f50a62a7d3cfdd17e52309386d8bce65c733 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 5 Jul 2026 16:53:15 -0300 Subject: [PATCH] repl: /results always shows the test picker; /validate recovered runs; Ctrl-C confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /results (interactive, no arg) now ALWAYS opens the run/test picker (target → vuln → detail, Esc back) instead of jumping straight to the current run's vulns. The live run (if any) appears at the top, past runs newest-first — so you can browse every test, not only the active one. - /validate [n]: re-run false-positive validation (N-model voting + adversarial refute) on a recovered/past run's findings WITHOUT re-testing the target, then rewrite that run's findings + report. Backed by new harness::pipeline::revalidate. Use this after a crash/quit recovered raw findings into /runs. - Ctrl-C at the prompt now CONFIRMS instead of silently cancelling: with a live run it offers [s]top&validate / [q]uit(keep findings) / keep-running; otherwise asks "exit? [y/N]" — so a stray Ctrl-C can't lose a running test. --- neurosploit-rs/app/src/repl.rs | 117 +++++++++++++++--- neurosploit-rs/crates/harness/src/pipeline.rs | 15 +++ 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index ebfab06..c387870 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -120,7 +120,7 @@ const COMMANDS: &[&str] = &[ "/help", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target", "/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline", "/votes", "/chain", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", - "/status", "/diff", "/retest", "/finding", "/expand", "/integrations", "/quit", + "/status", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations", "/quit", ]; /// rustyline helper: Tab-completes `/commands` and `@filesystem-paths`, @@ -304,7 +304,7 @@ impl Reader { } Some(l) } - Err(ReadlineError::Interrupted) => Some(String::new()), // Ctrl-C: cancel line + Err(ReadlineError::Interrupted) => Some(CTRL_C.to_string()), // Ctrl-C → confirm in loop Err(_) => None, // Ctrl-D / error: exit }, Reader::Plain(stdin) => { @@ -375,6 +375,33 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { } println!("{}", context_prompt(&s)); // dim context line above the prompt let Some(line) = reader.read(PROMPT) else { println!("\n bye."); break }; + // Ctrl-C → confirm before doing anything drastic (don't lose a live run). + if line == CTRL_C { + let run_active = active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false); + if run_active { + println!(" \x1b[33m⚠ a test is running.\x1b[0m [\x1b[36ms\x1b[0m] stop & validate/report · [\x1b[36mq\x1b[0m] quit (keep the run's findings) · [enter] keep running"); + match ask_line(" choice [s/q/enter]:").trim() { + "s" | "stop" => { + if let Some(a) = &active { + *a.choice.lock().unwrap() = StopMode::Validate; + a.soft.store(true, Ordering::Relaxed); + println!(" ⏸ stopping — validating what was found, then reporting…"); + } + } + "q" | "quit" => { + if let Some(a) = &active { a.cancel.store(true, Ordering::Relaxed); } + save_session(&s); println!(" session saved → {} · findings checkpointed. bye.", proj_dir().display()); break; + } + _ => println!(" (keep running — /status to check, /stop to halt)"), + } + } else { + match ask_line(" exit NeuroSploit? [y/N]:").trim().to_lowercase().as_str() { + "y" | "yes" | "q" => { save_session(&s); println!(" session saved → {} · bye.", proj_dir().display()); break; } + _ => {} + } + } + continue; + } let line = line.trim(); if line.is_empty() { continue; @@ -594,20 +621,61 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { println!(" ↻ retest set up for {} ({} prior finding(s)) — /run to launch", r.target, titles.len()); } } - "/results" => { - // Live findings while a run is active (no arg), else a past run. - match &active { - Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => { - let l = a.live.lock().unwrap(); - println!(" ▶ live — {} possible finding(s) so far ({})", l.full.len(), l.phase); - let mut f = l.full.clone(); - f.sort_by_key(|x| sev_rank(&x.severity)); - for x in &f { println!(" • [{}] {} \x1b[2m({} · {})\x1b[0m", x.severity, x.title, x.agent, x.endpoint); } - if !f.is_empty() { println!(" \x1b[2m/finding — pick one to see the command & PoC\x1b[0m"); } + "/validate" | "/revalidate" => { + // Re-run false-positive validation (voting + adversarial refute) on + // a recovered/past run's findings WITHOUT re-testing the target. + let (target, workdir, cands) = { + let h = history.lock().unwrap(); + match pick(&h, arg) { + Some(r) => (r.target.clone(), r.workdir.clone(), r.findings.clone()), + None => { continue; } } - // No arg + interactive → the full navigation browser (target → vuln → detail, Esc back). - _ if arg.is_empty() && std::io::stdin().is_terminal() => browse_results(&history.lock().unwrap()), - _ => results(&history.lock().unwrap(), arg), + }; + if cands.is_empty() { println!(" that run has no findings to validate."); continue; } + if s.offline { println!(" \x1b[31mvalidation needs a model — turn /offline off (and set a model/login).\x1b[0m"); continue; } + println!(" \x1b[1;35m▶ validating {} finding(s) from {}\x1b[0m (voting + adversarial refute)…", cands.len(), target); + let refs: Vec = s.models.iter().map(|m| ModelRef::parse(m)).collect(); + let pool = harness::pool::ModelPool::with_auth(refs, 3, s.subscription, None); + let (tx, mut rx) = tokio::sync::mpsc::channel::(256); + let vote_n = s.vote_n; + let task = tokio::spawn(async move { harness::pipeline::revalidate(cands, &pool, vote_n, tx).await }); + while let Some(line) = rx.recv().await { + if let Some(out) = crate::render_compact(&line) { println!("{out}"); } + } + let validated = task.await.unwrap_or_default(); + // Persist: rewrite the run's findings + report, save history. + { + let mut h = history.lock().unwrap(); + if let Some(r) = h.iter_mut().find(|r| r.workdir == workdir || (arg.trim().parse::().ok() == Some(r.id))) { + r.findings = validated.clone(); + } + save_runs(base, &h); + } + if !workdir.is_empty() { + crate::report_raw(&target, &validated, std::path::Path::new(&workdir)); + } + println!(" \x1b[1;32m✓ validation complete — {} finding(s) confirmed\x1b[0m · report refreshed · /results to browse", validated.len()); + } + "/results" => { + // With an explicit run number, or piped stdin → plain print. + if !arg.is_empty() || !std::io::stdin().is_terminal() { + results(&history.lock().unwrap(), arg); + } else { + // Interactive: ALWAYS show the run/test picker (target → vuln → + // detail, Esc back). Includes the live run (if any) at the top so + // you can browse every test, not just the current one. + let mut runs: Vec = Vec::new(); + if let Some(a) = &active { + if !a.done.load(Ordering::Relaxed) { + let l = a.live.lock().unwrap(); + runs.push(RunRecord { + id: 0, mode: format!("{} ▶live", l.mode), target: l.target.clone(), + workdir: String::new(), findings: l.full.clone(), + }); + } + } + runs.extend(history.lock().unwrap().iter().rev().cloned()); // newest-first + browse_results(&runs); } } "/finding" | "/findings" => { @@ -1210,16 +1278,20 @@ fn browse_results(history: &[RunRecord]) { let run_items: Vec = history.iter().map(|r| { let c = sev_counts(&r.findings); let sev = if c.is_empty() { "0".into() } else { c.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(" ") }; - format!("#{} {:<9} {:<40} [{}]", r.id, r.mode, trunc(&r.target, 40), sev) + let id = if r.id == 0 { "live".to_string() } else { format!("#{}", r.id) }; + format!("{:<5} {:<14} {:<40} [{}]", id, r.mode, trunc(&r.target, 40), sev) }).collect(); let ri = match dialoguer::Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Results — select a target/run (Esc to return to the session)") - .items(&run_items).default(run_items.len().saturating_sub(1)).interact_opt() { + .with_prompt("Results — select a test/run (↑/↓, enter · Esc returns to the session)") + .items(&run_items).default(0).interact_opt() { Ok(Some(i)) => i, _ => { println!(" ← back to session"); return; } }; let r = &history[ri]; - if r.findings.is_empty() { println!(" run #{} — no validated findings.", r.id); continue; } + if r.findings.is_empty() { + println!(" {} — no findings yet.", if r.id == 0 { "live run".into() } else { format!("run #{}", r.id) }); + continue; + } let mut f = r.findings.clone(); f.sort_by_key(|x| sev_rank(&x.severity)); // Level 2 — pick a vulnerability (Esc → back to target list). @@ -1325,7 +1397,8 @@ fn help() { h("/report [n]", "open a run's report (menu if several)"); h("/runs", "list all runs"); h("/diff", "what changed vs the last run"); - h("/retest [n]", "re-verify a past run's findings"); + h("/retest [n]", "re-verify a past run's findings (re-runs the test)"); + h("/validate [n]", "false-positive validate a recovered/past run (no re-test)"); println!("\n \x1b[2mINTEGRATIONS\x1b[0m"); h("/integrations", "show · enable/disable github|gitlab|jira · setup "); @@ -1426,6 +1499,10 @@ fn context_prompt(s: &Session) -> String { /// correctly; color is applied by the Highlighter, not embedded here. const PROMPT: &str = "neurosploit› "; +/// Sentinel returned by the reader on Ctrl-C so the loop can confirm before +/// exiting (instead of losing an active run to a stray interrupt). +const CTRL_C: &str = "\u{0}__ctrl_c__"; + /// Split the session target into one or more URLs (comma-separated list). fn session_targets(s: &Session) -> Vec { s.target.as_deref().map(|t| t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()) diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 9961d26..2e50b45 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -122,6 +122,21 @@ fn ua_line() -> String { /// hard to silently re-badge NeuroSploit's output as someone else's work. const ATTRIBUTION: &str = "Identified and validated by NeuroSploit (multi-model adversarial validation) — https://github.com/JoasASantos/NeuroSploit · by Joas A Santos & Red Team Leaders."; +/// Re-validate a set of candidate findings (N-model voting + adversarial refute) +/// WITHOUT re-running recon/exploitation — for recovered/interrupted runs, so the +/// operator can filter false positives on what was already found. Streams +/// progress and returns the surviving, attribution-stamped findings. +pub async fn revalidate(findings: Vec, pool: &ModelPool, vote_n: usize, tx: Sender) -> Vec { + pool.set_progress(tx.clone()); + let _ = tx.send(format!("re-validating {} recovered finding(s) by {}-model vote…", findings.len(), vote_n)).await; + let deduped = dedup_findings(findings); + let mut v = validate(deduped, pool, VOTE_SYS, vote_n, &tx).await; + v = refute_pass(v, pool, vote_n, &tx).await; + stamp_attribution(&mut v); + let _ = tx.send(format!("re-validation done — {} finding(s) survived", v.len())).await; + v +} + /// Append the NeuroSploit attribution to each finding's impact (idempotent). pub fn stamp_attribution(findings: &mut [Finding]) { for f in findings.iter_mut() {