From c7e756ffa300c2ec643ba1b80add53b6e246aa1c Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Wed, 1 Jul 2026 23:16:00 -0300 Subject: [PATCH] repl: idle guardrail, multi-target, results navigation; deeper recon prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REPL (v3.5.5): - /timeout : idle guardrail — if no NEW finding lands within the window the run soft-stops and validates what was found (default 5 min; 0 disables). - /target accepts a comma-separated list; /run tests them SEQUENTIALLY (a queue auto-advances to the next target when the current run finishes; one report each). - /results (no arg, interactive): navigation browser — pick target/run → pick vulnerability → full detail; Esc steps back a level (vuln → target → session). - /report (no arg, multiple runs): pick which report to open from a menu. - /show now shows idle-stop; help updated. Agent prompts: - RECON_SYS deepened: crawl + params/headers/cookies, DOWNLOAD & analyze linked JS (endpoints, hidden params, GraphQL, secrets, sourceMappingURL), fingerprint exact versions, response-differential analysis; richer JSON schema. - tool_doctrine adds JS-analysis and request/response-analysis guidance (linkfinder/gau/katana, header/cookie/timing/length differentials). --- neurosploit-rs/app/src/repl.rs | 197 +++++++++++++++--- neurosploit-rs/crates/harness/src/pipeline.rs | 19 +- 2 files changed, 185 insertions(+), 31 deletions(-) diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 16325a9..389aa56 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -119,7 +119,7 @@ struct LiveCheckpoint { const COMMANDS: &[&str] = &[ "/help", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target", "/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline", - "/votes", "/chain", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", + "/votes", "/chain", "/timeout", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", "/status", "/diff", "/retest", "/integrations", "/quit", ]; @@ -214,6 +214,9 @@ struct Session { vote_n: usize, max_agents: usize, chain_depth: usize, + /// Idle guardrail: stop a run if no NEW finding lands in this many seconds + /// (0 = disabled). Set in minutes via `/timeout `. + idle_secs: u64, offline: bool, target: Option, repo: Option, @@ -233,6 +236,7 @@ impl Default for Session { vote_n: 3, max_agents: 0, chain_depth: 2, + idle_secs: 300, // 5-minute idle guardrail by default offline: false, target: None, repo: None, @@ -353,9 +357,16 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { println!(); let mut reader = Reader::new(base); let mut active: Option = None; + let mut queue: Vec = Vec::new(); // remaining targets for a multi-target /run show(&s); loop { + // Multi-target queue: when the current run finishes, auto-start the next. + if !queue.is_empty() && active.as_ref().map(|a| a.done.load(Ordering::Relaxed)).unwrap_or(true) { + let next = queue.remove(0); + println!("\n \x1b[1;35m▶ next target\x1b[0m ({} left): {next}", queue.len()); + active = start_background(base, &s, &mut reader, history.clone(), Some(&next)).await; + } println!("{}", context_prompt(&s)); // dim context line above the prompt let Some(line) = reader.read(PROMPT) else { println!("\n bye."); break }; let line = line.trim(); @@ -404,10 +415,28 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { println!(" subscription: {}", onoff(s.subscription)); } "/target" | "/url" => { - if arg.is_empty() { println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none) — set with /target , clear with /target clear".into())); } + if arg.is_empty() { println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none) — set with /target , clear with /target clear".into())); } else if arg == "clear" { s.target = None; println!(" target cleared"); } - else { let t = if arg.starts_with("http") { arg.to_string() } else { format!("https://{arg}") }; - s.target = Some(t.clone()); println!(" target: {t}"); } + else { + // Accept one URL or a comma-separated list; normalize each. + let ts: Vec = arg.split(',').map(|x| x.trim()).filter(|x| !x.is_empty()) + .map(|x| if x.starts_with("http") { x.to_string() } else { format!("https://{x}") }) + .collect(); + s.target = Some(ts.join(",")); + if ts.len() > 1 { println!(" targets ({}): {}", ts.len(), ts.join(", ")); println!(" \x1b[2m/run tests them sequentially, one report each\x1b[0m"); } + else { println!(" target: {}", ts.first().cloned().unwrap_or_default()); } + } + } + "/timeout" | "/idle" => { + if arg.is_empty() { + if s.idle_secs == 0 { println!(" idle guardrail: off — set minutes with /timeout (0 disables)"); } + else { println!(" idle guardrail: stop if no new finding in {} min — /timeout (0 disables)", s.idle_secs / 60); } + } else { + let mins: u64 = arg.trim().parse().unwrap_or(s.idle_secs / 60); + s.idle_secs = mins.saturating_mul(60); + if mins == 0 { println!(" idle guardrail: off"); } + else { println!(" idle guardrail: stop if no new finding in {mins} min"); } + } } "/repo" => { if arg.is_empty() { println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none) — set with /repo , clear with /repo clear".into())); } @@ -472,11 +501,21 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { println!(" a run is already active — /status to check, /stop to halt it."); } else { save_session(&s); - match start_background(base, &s, &mut reader, history.clone()).await { + // Multiple comma-separated targets → run sequentially (queue the rest). + let targets = session_targets(&s); + let (first, rest): (Option, Vec) = if targets.len() > 1 { + (Some(targets[0].clone()), targets[1..].to_vec()) + } else { (None, Vec::new()) }; + queue = rest; + if !queue.is_empty() { + println!(" \x1b[1;35m▶ multi-target\x1b[0m: {} URLs — running sequentially", targets.len()); + } + match start_background(base, &s, &mut reader, history.clone(), first.as_deref()).await { Some(a) => { active = Some(a); println!(" \x1b[1;35m▶ running in background\x1b[0m — keep typing · \x1b[36m/status\x1b[0m · \x1b[36m/stop\x1b[0m"); } None => { // no external printer (piped) → blocking fallback let mut h = history.lock().unwrap(); run(base, &s, &mut h).await; save_runs(base, &h); + queue.clear(); } } } @@ -543,6 +582,8 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { 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"); } } + // 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), } } @@ -739,13 +780,16 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { /// external printer while the REPL keeps accepting commands (/status, /stop). /// Returns None when no external printer is available (piped) → caller blocks. async fn start_background(base: &Path, s: &Session, reader: &mut Reader, - history: Arc>>) -> Option { - let (target, mode_s, mode_e, mcp) = match (&s.repo, &s.target) { + history: Arc>>, target_override: Option<&str>) -> Option { + // `target_override` runs one specific URL (used by the multi-target queue). + let ov = target_override.map(|t| t.to_string()); + let (target, mode_s, mode_e, mcp) = match (&s.repo, ov.as_ref().or(s.target.as_ref())) { (Some(_), Some(t)) => (t.clone(), "greybox", crate::Mode::Grey, s.mcp), (Some(r), None) => (r.clone(), "white-box", crate::Mode::White, false), (None, Some(t)) => (t.clone(), "black-box", crate::Mode::Black, s.mcp), _ => { println!(" \x1b[31m✗ set a /target and/or /repo first.\x1b[0m"); return None; } }; + let idle_secs = s.idle_secs; let mut cfg = RunConfig::new(&target); cfg.models = s.models.clone(); cfg.subscription = s.subscription; @@ -775,28 +819,52 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader, let fallback = sp.fallback.clone(); let done = Arc::new(AtomicBool::new(false)); let choice = Arc::new(Mutex::new(StopMode::Run)); + let soft_task = soft.clone(); // idle guardrail triggers a soft-stop (validate) + let cancel_task = cancel.clone(); let (live2, done2, hist2, choice2) = (live.clone(), done.clone(), history, choice.clone()); tokio::spawn(async move { let crate::Spawned { task, mut rx, workdir, .. } = sp; let mut last_saved = 0usize; - while let Some(line) = rx.recv().await { - live2.lock().unwrap().ingest(&line); - if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); } - // Checkpoint live findings to disk whenever a new one lands, so the - // run survives a quit/crash and is recovered on next launch. - let snap = { - let l = live2.lock().unwrap(); - if l.full.len() != last_saved { - last_saved = l.full.len(); - Some(LiveCheckpoint { - target: l.target.clone(), mode: l.mode.into(), phase: l.phase.clone(), - workdir: workdir.display().to_string(), - findings: l.full.clone(), commands: l.commands.clone(), - }) - } else { None } - }; - if let Some(c) = snap { save_checkpoint(&c); } + let mut last_find = Instant::now(); // time of the last NEW finding + let mut idle_fired = false; + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + maybe = rx.recv() => { + let Some(line) = maybe else { break }; + live2.lock().unwrap().ingest(&line); + if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); } + // Checkpoint on each new finding; also resets the idle clock. + let snap = { + let l = live2.lock().unwrap(); + if l.full.len() != last_saved { + last_saved = l.full.len(); + last_find = Instant::now(); + Some(LiveCheckpoint { + target: l.target.clone(), mode: l.mode.into(), phase: l.phase.clone(), + workdir: workdir.display().to_string(), + findings: l.full.clone(), commands: l.commands.clone(), + }) + } else { None } + }; + if let Some(c) = snap { save_checkpoint(&c); } + } + _ = ticker.tick() => { + // Idle guardrail: no NEW finding within the window → soft-stop + // (stop launching exploit agents, validate what was found). + if idle_secs > 0 && !idle_fired && last_find.elapsed().as_secs() >= idle_secs + && !soft_task.load(Ordering::Relaxed) && !cancel_task.load(Ordering::Relaxed) { + idle_fired = true; + *choice2.lock().unwrap() = StopMode::Validate; + soft_task.store(true, Ordering::Relaxed); + let _ = printer.print(format!( + "\x1b[33m⏹ idle guardrail: no new finding in {} min — stopping & validating what was found\x1b[0m", + idle_secs / 60)); + } + } + } } let task_out = task.await.unwrap_or_default(); let mode_choice = *choice2.lock().unwrap(); @@ -942,7 +1010,24 @@ fn results(history: &[RunRecord], arg: &str) { } fn open_report(history: &[RunRecord], arg: &str) { - let Some(r) = pick(history, arg) else { return }; + if history.is_empty() { println!(" no runs yet — /run first."); return; } + // No arg + multiple runs + interactive → let the user pick which report. + let chosen: Option<&RunRecord> = if arg.trim().is_empty() && history.len() > 1 && std::io::stdin().is_terminal() { + let items: Vec = history.iter().map(|r| { + let c = sev_counts(&r.findings); + let sev = if c.is_empty() { "0 findings".into() } else { c.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(" ") }; + format!("#{} {:<9} {:<40} [{}]", r.id, r.mode, trunc(&r.target, 40), sev) + }).collect(); + match dialoguer::Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select a report to open (↑/↓, enter, Esc)") + .items(&items).default(items.len() - 1).interact_opt() { + Ok(Some(i)) => history.get(i), + _ => return, + } + } else { + pick(history, arg) + }; + let Some(r) = chosen else { return }; let dir = Path::new(&r.workdir); let pdf = dir.join("report.pdf"); let file = if pdf.is_file() { pdf } else { dir.join("report.html") }; @@ -1055,7 +1140,11 @@ fn finding_detail(pool: &[Finding]) { Ok(Some(i)) => i, _ => return, } } else { 0 }; - let x = &f[idx]; + print_finding_detail(&f[idx]); +} + +/// Full detail card for one finding. +fn print_finding_detail(x: &Finding) { println!("\n ┌─ \x1b[1m{}\x1b[0m", x.title); println!(" │ severity : {}", x.severity); println!(" │ cwe / cvss : {} · {}", x.cwe, x.cvss); @@ -1073,6 +1162,49 @@ fn finding_detail(pool: &[Finding]) { println!(" └─────"); } +/// Interactive results browser: pick a target/run → pick a vulnerability → see +/// full detail. Esc steps back a level (vuln list → target list → exit to REPL). +fn browse_results(history: &[RunRecord]) { + if history.is_empty() { println!(" no runs yet — /run first."); return; } + if !std::io::stdin().is_terminal() { results(history, ""); return; } + loop { + // Level 1 — pick a run/target. + 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) + }).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() { + 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; } + 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). + loop { + let items: Vec = f.iter().map(|x| format!("[{}] {} — {}", x.severity, x.title, x.cwe)).collect(); + let fi = match dialoguer::Select::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("#{} {} — select a vulnerability (Esc = back)", r.id, trunc(&r.target, 36))) + .items(&items).default(0).interact_opt() { + Ok(Some(i)) => i, + _ => break, // Esc → back to target list + }; + print_finding_detail(&f[fi]); + // Enter → back to the vuln list; Esc → back to the target list. + match dialoguer::Select::with_theme(&ColorfulTheme::default()) + .with_prompt("↵ back to vulnerabilities · Esc = back to targets") + .items(&["back"]).default(0).interact_opt() { + Ok(None) => break, + _ => {} + } + } + } +} + fn run_status(history: &[RunRecord], arg: &str) { let Some(r) = pick(history, arg) else { return }; match std::fs::read_to_string(Path::new(&r.workdir).join("status.json")) { @@ -1097,7 +1229,9 @@ fn show(s: &Session) { println!(" │ auth : {}", s.auth.clone().unwrap_or_else(|| "(none)".into())); println!(" │ creds : {}", s.creds.clone().unwrap_or_else(|| "(none)".into())); println!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into())); - println!(" │ opts : mcp={} offline={} votes={} chain-depth={} max-agents={}", onoff(s.mcp), onoff(s.offline), s.vote_n, s.chain_depth, s.max_agents); + println!(" │ opts : mcp={} offline={} votes={} chain-depth={} max-agents={} idle-stop={}", + onoff(s.mcp), onoff(s.offline), s.vote_n, s.chain_depth, s.max_agents, + if s.idle_secs == 0 { "off".to_string() } else { format!("{}m", s.idle_secs / 60) }); // Integrations at a glance (see /integrations for detail). { let ig = harness::integrations::Integrations::load(&proj_dir()); @@ -1126,7 +1260,7 @@ fn help() { println!("\n \x1b[1mNeuroSploit REPL — commands\x1b[0m"); println!("\n \x1b[2mTARGET & SCOPE\x1b[0m"); - h("/target ", "black-box target URL"); + h("/target ", "black-box target URL (comma-separated = multi-target, sequential)"); h("/repo ", "analyse a repo (repo + target = greybox: code + live)"); h("/auth ", "auth header, e.g. 'Authorization: Bearer ' (no arg = show)"); h("/creds ", "credentials: jwt/header/cookie/login + ssh/windows"); @@ -1154,6 +1288,7 @@ fn help() { println!("\n \x1b[2mOPTIONS\x1b[0m"); h("/mcp on|off", "Playwright MCP browser /offline on|off self-test"); h("/votes ", "validator votes /chain attack-chain depth"); + h("/timeout ", "idle guardrail: stop if no new finding in (0 = off)"); h("/agents |list", "cap agents · list counts /theme color|mono"); h("/show (config)", "/clear /quit"); @@ -1239,6 +1374,12 @@ fn context_prompt(s: &Session) -> String { /// correctly; color is applied by the Highlighter, not embedded here. const PROMPT: &str = "neurosploit› "; +/// 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()) + .unwrap_or_default() +} + fn onoff(b: bool) -> &'static str { if b { "on" } else { "off" } } fn trunc(s: &str, n: usize) -> String { if s.chars().count() <= n { s.to_string() } diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 3a806e1..8ca5121 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -22,7 +22,13 @@ pub struct RunOutput { pub artifacts: Vec, } -const RECON_SYS: &str = "You are a web recon specialist on an AUTHORIZED engagement. You have shell tools (curl etc.) — actively fetch the target, enumerate pages/params, and map the real attack surface. Do not ask for permission; proceed. Reply with a compact JSON object (tech, endpoints, params, auth, apis). No prose."; +const RECON_SYS: &str = "You are an elite web recon specialist on an AUTHORIZED engagement. Actively fetch the target with your tools and map the REAL attack surface in DEPTH — do not ask for permission, proceed:\n\ +- Crawl pages, forms and parameters; record every input, header, cookie and redirect.\n\ +- DOWNLOAD the linked JavaScript bundles (curl each script) and ANALYZE them: extract API endpoints/routes, hidden/undocumented parameters, GraphQL operations, secrets / API keys / tokens, cloud & third-party URLs, feature flags, and `sourceMappingURL` references (fetch source maps if exposed to recover original source).\n\ +- Fingerprint the tech stack and EXACT versions (server, framework, libraries, CMS, JS libs) from headers, HTML, asset paths and JS.\n\ +- Analyze responses deeply: status codes, ALL headers, Set-Cookie flags, verbose errors/stack traces, content types, and length/timing differentials.\n\ +- Map auth (cookie/JWT/OAuth), APIs (REST & GraphQL), and any dev/staging/internal hosts referenced anywhere.\n\ +Base everything on real observed responses — never assume. Reply with a COMPACT JSON object with keys {tech, versions, endpoints, params, apis, auth, js_findings, secrets, hosts, notes}. No prose."; /// Operator directives (focus instructions + auth material) prepended to /// recon/exploit prompts so the engagement is steered as the user asked. @@ -51,11 +57,18 @@ fn tool_doctrine(mcp_on: bool) -> String { }; format!( "TOOLING (authorized; best on Kali Linux or the kalilinux/kali-rolling Docker image):\n\ - - HTTP: `curl` (headers, methods, params, cookies), `wget`.\n\ + - HTTP: `curl` (dump ALL response headers with -i/-D-, follow/inspect redirects, set methods/params/cookies), `wget`.\n\ - Ports/services: `rustscan` if present, else `nmap`; if neither is installed you may \ install via apt (`apt install -y nmap`), brew, or cargo (`cargo install rustscan`) — \ otherwise probe common ports with `curl`/`nc`.\n\ - - Content/params: `ffuf`, `gobuster`, `gau`, `katana` when available.\n\ + - Content/params/URLs: `ffuf`, `gobuster`, `gau`, `katana`, `waybackurls`, `linkfinder` when available.\n\ + - JS ANALYSIS: download every linked script (`curl -s `) and grep it for endpoints/paths, \ + `fetch(`/`axios`/XHR URLs, API & GraphQL routes, hidden params, and secrets (AKIA…, `api_key`, `token`, \ + `Bearer `, `authorization`), plus `sourceMappingURL` (fetch the .map to recover original source). \ + Prefer `linkfinder`/`gau`/`katana` to harvest more URLs when present, else regex with `grep -Eo`.\n\ + - REQUEST/RESPONSE ANALYSIS: read status codes, every header, Set-Cookie flags, content-type, body length \ + and response timing; use DIFFERENTIALS (authenticated vs anonymous, valid vs invalid input, existing vs \ + missing resource) and reflected input / verbose errors to infer behavior and CONFIRM issues with evidence.\n\ - {browser}\n\ Use only what is installed; degrade gracefully. Never run destructive or DoS actions.\n\n" )