mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-07-06 11:27:52 +02:00
repl: /results always shows the test picker; /validate recovered runs; Ctrl-C confirm
- /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.
This commit is contained in:
@@ -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<ModelRef> = 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::<String>(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::<usize>().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<RunRecord> = 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<String> = 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::<Vec<_>>().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 <name>");
|
||||
@@ -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<String> {
|
||||
s.target.as_deref().map(|t| t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect())
|
||||
|
||||
@@ -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<Finding>, pool: &ModelPool, vote_n: usize, tx: Sender<String>) -> Vec<Finding> {
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user