repl: idle guardrail, multi-target, results navigation; deeper recon prompts

REPL (v3.5.5):
- /timeout <min>: 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).
This commit is contained in:
CyberSecurityUP
2026-07-01 23:16:00 -03:00
parent 78b638a956
commit c7e756ffa3
2 changed files with 185 additions and 31 deletions
+169 -28
View File
@@ -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 <mins>`.
idle_secs: u64,
offline: bool,
target: Option<String>,
repo: Option<String>,
@@ -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<ActiveRun> = None;
let mut queue: Vec<String> = 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 <url>, clear with /target clear".into())); }
if arg.is_empty() { println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none) — set with /target <url[,url2,...]>, 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<String> = 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 <n> (0 disables)"); }
else { println!(" idle guardrail: stop if no new finding in {} min — /timeout <n> (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 <path | github-url | owner/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<String>, Vec<String>) = 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<RunRecord>) {
/// 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<Mutex<Vec<RunRecord>>>) -> Option<ActiveRun> {
let (target, mode_s, mode_e, mcp) = match (&s.repo, &s.target) {
history: Arc<Mutex<Vec<RunRecord>>>, target_override: Option<&str>) -> Option<ActiveRun> {
// `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 <url> and/or /repo <path> 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<String> = 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::<Vec<_>>().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<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)
}).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<String> = 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 <url>", "black-box target URL");
h("/target <url[,..]>", "black-box target URL (comma-separated = multi-target, sequential)");
h("/repo <path>", "analyse a repo (repo + target = greybox: code + live)");
h("/auth <value>", "auth header, e.g. 'Authorization: Bearer <jwt>' (no arg = show)");
h("/creds <file.yaml>", "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 <n>", "validator votes /chain <n> attack-chain depth");
h("/timeout <min>", "idle guardrail: stop if no new finding in <min> (0 = off)");
h("/agents <n>|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<String> {
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() }
+16 -3
View File
@@ -22,7 +22,13 @@ pub struct RunOutput {
pub artifacts: Vec<String>,
}
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 <script.js>`) 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"
)