mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-07-07 03:47:56 +02:00
39c28b541b
- DECISION_DOCTRINE injected into exploit/grey/chain prompts: analyse responses to pick the technique; map & connect routes (endpoint output → next endpoint input); hunt sensitive flows; mine parameters (incl. hidden from JS/source maps) and test per-param; mock realistic (non-PII) data to reach deeper logic; exploit the authenticated surface after login and compare roles; build PoCs when a proof needs an artifact; bypass 401/403/redirect controls. - REPL /auth now supports multiple named identities (/auth admin <hdr>, /auth user <hdr>; bare token → Bearer). With >=2 roles the run gets the access-control directive (IDOR/BOLA/BFLA/privesc, authorized-vs-unauthorized) and tests both. - +6 decision agents (library 389): param_miner, endpoint_flow_linker, authenticated_surface_exploit, clickjacking_poc (HTML PoC), csrf_poc (HTML PoC), access_control_bypass. - Docs: counts 383->389, RELEASE + /auth help updated.
1572 lines
81 KiB
Rust
1572 lines
81 KiB
Rust
//! NeuroSploit v3.5.5 — interactive session (Claude-Code / Codex / Cursor-CLI style).
|
||
//!
|
||
//! Launched when `neurosploit` runs with no subcommand. A persistent REPL with
|
||
//! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model
|
||
//! selection (arrow-key multi-select), API-key configuration based on the chosen
|
||
//! models, target/repo/auth/instructions, run history, and reports.
|
||
|
||
use dialoguer::{theme::ColorfulTheme, MultiSelect};
|
||
use harness::{agents, models::ModelRef, types::Finding, types::RunConfig};
|
||
use rustyline::completion::{Completer, Pair};
|
||
use rustyline::error::ReadlineError;
|
||
use rustyline::highlight::Highlighter;
|
||
use rustyline::hint::Hinter;
|
||
use rustyline::history::FileHistory;
|
||
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
|
||
use rustyline::{Cmd, CompletionType, Config, Context, Editor, ExternalPrinter, Helper, KeyEvent};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::io::IsTerminal;
|
||
use std::path::Path;
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Instant;
|
||
|
||
/// Live state of a background run, updated from the engagement stream so the
|
||
/// composer can answer /status while the runner works.
|
||
struct RunLive {
|
||
target: String,
|
||
mode: &'static str,
|
||
phase: String,
|
||
started: Instant,
|
||
findings: Vec<(String, String)>, // sev, title (summary)
|
||
full: Vec<Finding>, // full candidate findings (PoC, evidence) for /finding
|
||
commands: Vec<String>, // full untruncated commands for /expand & Ctrl+O
|
||
agents: usize,
|
||
agents_done: usize,
|
||
}
|
||
impl RunLive {
|
||
/// progress fraction in [0,1] (agents completed / total selected).
|
||
fn progress(&self) -> f64 {
|
||
if self.agents == 0 { return 0.0; }
|
||
(self.agents_done as f64 / self.agents as f64).clamp(0.0, 1.0)
|
||
}
|
||
fn bar(&self, width: usize) -> String {
|
||
let filled = (self.progress() * width as f64).round() as usize;
|
||
format!("[{}{}] {}/{} ({:.0}%)",
|
||
"█".repeat(filled), "░".repeat(width.saturating_sub(filled)),
|
||
self.agents_done, self.agents, self.progress() * 100.0)
|
||
}
|
||
fn ingest(&mut self, line: &str) {
|
||
let low = line.to_lowercase();
|
||
if low.contains("token/quota exhausted") || low.contains("run is paused") { self.phase = "paused (quota)".into(); }
|
||
else if low.contains("resumed — retrying") { self.phase = "exploiting".into(); }
|
||
else if low.contains("recon complete") { self.phase = "recon".into(); }
|
||
else if low.contains("selected") && low.contains("agent") {
|
||
self.phase = "planning".into();
|
||
if let Some(n) = line.split_whitespace().find_map(|t| t.parse::<usize>().ok()) { self.agents = n; }
|
||
}
|
||
else if low.starts_with("exploit") || low.starts_with("test ") || low.contains("launching agent") { self.phase = "exploiting".into(); }
|
||
else if low.starts_with("vote") || low.contains("validating") { self.phase = "validating".into(); }
|
||
else if low.starts_with("chain") { self.phase = "chaining".into(); }
|
||
else if low.contains("phase complete") || low.contains("validated finding(s)") { self.phase = "complete".into(); }
|
||
// count completed agents (each emits "... via <model> → N candidate(s)")
|
||
if low.contains("candidate(s)") && (low.starts_with("exploit ") || low.starts_with("test ") || low.starts_with("analyze ") || low.starts_with("review ")) {
|
||
self.agents_done += 1;
|
||
}
|
||
if let Some(rest) = line.strip_prefix("finding: ") {
|
||
if let Some(b) = rest.strip_prefix('[') {
|
||
if let Some((sev, tail)) = b.split_once(']') {
|
||
let title = tail.trim().split(" @ ").next().unwrap_or(tail.trim());
|
||
self.findings.push((sev.to_string(), title.to_string()));
|
||
}
|
||
}
|
||
}
|
||
// Full candidate finding (with PoC/evidence) for /results & /finding.
|
||
if let Some(j) = line.strip_prefix("finding_json: ") {
|
||
if let Ok(f) = serde_json::from_str::<Finding>(j) { self.full.push(f); }
|
||
}
|
||
// Full untruncated command for /expand & Ctrl+O.
|
||
let cmd_part = line.strip_prefix('@').and_then(|s| s.split_once(' ').map(|(_, r)| r)).unwrap_or(line);
|
||
if let Some(c) = cmd_part.strip_prefix("exec: ").or_else(|| cmd_part.strip_prefix("danger: ")) {
|
||
self.commands.push(c.to_string());
|
||
if self.commands.len() > 100 { self.commands.remove(0); }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What to do when the user stops a run.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum StopMode { Run, Validate, Raw, Discard }
|
||
|
||
/// A run executing in the background of the REPL.
|
||
struct ActiveRun {
|
||
live: Arc<Mutex<RunLive>>,
|
||
cancel: Arc<AtomicBool>,
|
||
soft: Arc<AtomicBool>,
|
||
done: Arc<AtomicBool>,
|
||
choice: Arc<Mutex<StopMode>>,
|
||
/// Set when the run is parked on token/quota exhaustion (awaiting /continue).
|
||
paused: Arc<AtomicBool>,
|
||
/// Wakes the parked run when the user runs /continue.
|
||
resume: Arc<tokio::sync::Notify>,
|
||
/// Fallback models to try first, pushed by /continue <provider:model>.
|
||
fallback: Arc<Mutex<Vec<ModelRef>>>,
|
||
}
|
||
|
||
/// On-disk checkpoint of an in-flight run's findings/commands, written live so a
|
||
/// run survives quitting/crashing — recovered into /runs on the next launch.
|
||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||
struct LiveCheckpoint {
|
||
target: String,
|
||
mode: String,
|
||
phase: String,
|
||
workdir: String,
|
||
findings: Vec<Finding>,
|
||
commands: Vec<String>,
|
||
}
|
||
|
||
/// All slash-commands, for Tab completion.
|
||
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", "/validate", "/finding", "/expand", "/integrations", "/quit",
|
||
];
|
||
|
||
/// rustyline helper: Tab-completes `/commands` and `@filesystem-paths`,
|
||
/// and supports multiline input (a line ending with `\` continues).
|
||
struct NsHelper;
|
||
|
||
impl Completer for NsHelper {
|
||
type Candidate = Pair;
|
||
fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> rustyline::Result<(usize, Vec<Pair>)> {
|
||
let head = &line[..pos];
|
||
// current "word" = text after the last whitespace
|
||
let start = head.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
|
||
let word = &head[start..];
|
||
if let Some(p) = word.strip_prefix('@') {
|
||
return Ok((start, complete_path(p)));
|
||
}
|
||
if word.starts_with('/') || (start == 0 && word.is_empty()) {
|
||
let cands = COMMANDS.iter()
|
||
.filter(|c| c.starts_with(word))
|
||
.map(|c| Pair { display: c.to_string(), replacement: format!("{c} ") })
|
||
.collect();
|
||
return Ok((start, cands));
|
||
}
|
||
Ok((start, vec![]))
|
||
}
|
||
}
|
||
|
||
fn complete_path(prefix: &str) -> Vec<Pair> {
|
||
let (dir, frag) = match prefix.rfind('/') {
|
||
Some(i) => (&prefix[..=i], &prefix[i + 1..]),
|
||
None => ("", prefix),
|
||
};
|
||
let read_dir = if dir.is_empty() { ".".to_string() } else { dir.to_string() };
|
||
let mut out = Vec::new();
|
||
if let Ok(entries) = std::fs::read_dir(&read_dir) {
|
||
for e in entries.flatten() {
|
||
let name = e.file_name().to_string_lossy().to_string();
|
||
if name.starts_with(frag) {
|
||
let is_dir = e.path().is_dir();
|
||
let full = format!("@{dir}{name}{}", if is_dir { "/" } else { "" });
|
||
out.push(Pair { display: format!("{name}{}", if is_dir { "/" } else { "" }), replacement: full });
|
||
}
|
||
}
|
||
}
|
||
out.truncate(40);
|
||
out
|
||
}
|
||
|
||
impl Hinter for NsHelper { type Hint = String; }
|
||
impl Highlighter for NsHelper {
|
||
// Color the prompt for display only. rustyline measures the ORIGINAL (plain)
|
||
// prompt for cursor width, so adding ANSI here does NOT break line editing —
|
||
// unlike embedding escapes in the prompt string passed to readline().
|
||
fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
|
||
&'s self,
|
||
prompt: &'p str,
|
||
_default: bool,
|
||
) -> std::borrow::Cow<'b, str> {
|
||
if prompt.trim_start().starts_with("neurosploit") {
|
||
std::borrow::Cow::Owned(format!("\x1b[35m{prompt}\x1b[0m"))
|
||
} else {
|
||
std::borrow::Cow::Borrowed(prompt)
|
||
}
|
||
}
|
||
}
|
||
impl Validator for NsHelper {
|
||
fn validate(&self, ctx: &mut ValidationContext<'_>) -> rustyline::Result<ValidationResult> {
|
||
if ctx.input().ends_with('\\') {
|
||
Ok(ValidationResult::Incomplete) // multiline: backslash continues
|
||
} else {
|
||
Ok(ValidationResult::Valid(None))
|
||
}
|
||
}
|
||
}
|
||
impl Helper for NsHelper {}
|
||
|
||
/// A run completed within this session (persisted to disk for /runs across sessions).
|
||
#[derive(Serialize, Deserialize, Clone)]
|
||
struct RunRecord {
|
||
id: usize,
|
||
mode: String,
|
||
target: String,
|
||
workdir: String,
|
||
findings: Vec<Finding>,
|
||
}
|
||
|
||
struct Session {
|
||
models: Vec<String>,
|
||
subscription: bool,
|
||
mcp: bool,
|
||
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,
|
||
/// Local intercepting proxy (Burp/ZAP), e.g. http://127.0.0.1:8080.
|
||
proxy: Option<String>,
|
||
/// Identifying User-Agent for NeuroSploit traffic (None = default UA).
|
||
user_agent: Option<String>,
|
||
offline: bool,
|
||
target: Option<String>,
|
||
repo: Option<String>,
|
||
auth: Option<String>,
|
||
/// Named identities for multi-role access-control testing (name, header line).
|
||
roles: Vec<(String, String)>,
|
||
creds: Option<String>,
|
||
instructions: Option<String>,
|
||
attachments: Vec<String>,
|
||
color: bool,
|
||
}
|
||
|
||
impl Default for Session {
|
||
fn default() -> Self {
|
||
Session {
|
||
models: vec!["anthropic:claude-opus-4-8".into()],
|
||
subscription: harness::installed_cli_backends().contains(&"claude"),
|
||
mcp: false,
|
||
vote_n: 3,
|
||
max_agents: 0,
|
||
chain_depth: 2,
|
||
idle_secs: 300, // 5-minute idle guardrail by default
|
||
proxy: None,
|
||
user_agent: None,
|
||
offline: false,
|
||
target: None,
|
||
repo: None,
|
||
auth: None,
|
||
roles: Vec::new(),
|
||
creds: None,
|
||
instructions: None,
|
||
attachments: Vec::new(),
|
||
color: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Line reader: full rustyline editing (Tab-complete, history, multiline) when
|
||
/// interactive, plain stdin when piped.
|
||
enum Reader {
|
||
Rl(Box<Editor<NsHelper, FileHistory>>, std::path::PathBuf),
|
||
Plain(std::io::Stdin),
|
||
}
|
||
|
||
impl Reader {
|
||
fn new(_base: &Path) -> Reader {
|
||
if std::io::stdin().is_terminal() {
|
||
// List completion → @path shows a file/folder menu (Claude-Code-style).
|
||
let cfg = Config::builder().auto_add_history(false)
|
||
.completion_type(CompletionType::List).build();
|
||
if let Ok(mut ed) = Editor::<NsHelper, FileHistory>::with_config(cfg) {
|
||
ed.set_helper(Some(NsHelper));
|
||
// Ctrl+O pre-fills /expand to dump the last full (untruncated) commands.
|
||
ed.bind_sequence(KeyEvent::ctrl('o'), Cmd::Insert(1, "/expand".to_string()));
|
||
let hist = proj_dir().join("history.txt");
|
||
let _ = ed.load_history(&hist);
|
||
return Reader::Rl(Box::new(ed), hist);
|
||
}
|
||
}
|
||
Reader::Plain(std::io::stdin())
|
||
}
|
||
|
||
/// An external printer that can write *above* the prompt from another task —
|
||
/// this is what lets a background run stream live while you keep typing.
|
||
fn external_printer(&mut self) -> Option<Box<dyn ExternalPrinter + Send>> {
|
||
match self {
|
||
Reader::Rl(ed, _) => ed.create_external_printer().ok().map(|p| Box::new(p) as Box<dyn ExternalPrinter + Send>),
|
||
Reader::Plain(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Returns None to exit (EOF / Ctrl-D), Some(line) otherwise. Ctrl-C cancels
|
||
/// the current line (returns an empty string) instead of exiting.
|
||
/// `prompt` is the dynamic context bar + prompt to show.
|
||
fn read(&mut self, prompt: &str) -> Option<String> {
|
||
match self {
|
||
Reader::Rl(ed, hist) => match ed.readline(prompt) {
|
||
Ok(l) => {
|
||
// Join multiline input: a trailing `\` continued the line.
|
||
let l = l.replace("\\\n", " ").replace('\n', " ");
|
||
if !l.trim().is_empty() {
|
||
let _ = ed.add_history_entry(l.as_str());
|
||
let _ = ed.save_history(hist);
|
||
}
|
||
Some(l)
|
||
}
|
||
Err(ReadlineError::Interrupted) => Some(CTRL_C.to_string()), // Ctrl-C → confirm in loop
|
||
Err(_) => None, // Ctrl-D / error: exit
|
||
},
|
||
Reader::Plain(stdin) => {
|
||
use std::io::Write;
|
||
print!("{prompt}");
|
||
std::io::stdout().flush().ok();
|
||
let mut s = String::new();
|
||
match stdin.read_line(&mut s) {
|
||
Ok(0) | Err(_) => None,
|
||
Ok(_) => Some(s),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||
let lib = agents::load(base);
|
||
let backends = harness::installed_cli_backends();
|
||
println!("\x1b[1m");
|
||
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
|
||
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.5.5");
|
||
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
|
||
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
|
||
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
|
||
println!(" ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝\x1b[0m");
|
||
println!(" {} agents loaded · detected logins: {}", lib.total(),
|
||
if backends.is_empty() { "none (use API keys)".into() } else { backends.join(", ") });
|
||
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);
|
||
// Shared so a background run's forwarder task can append to it.
|
||
let history: Arc<Mutex<Vec<RunRecord>>> = Arc::new(Mutex::new(load_runs(base)));
|
||
let past = history.lock().unwrap().len();
|
||
if resumed || past > 0 {
|
||
println!(" ↻ resumed project session from {} — {} past run(s)", proj_dir().display(), past);
|
||
}
|
||
// Recover an interrupted run (REPL was quit/crashed mid-engagement): its
|
||
// live findings were checkpointed to disk — fold them into /runs so
|
||
// /results, /finding and /report still work.
|
||
if let Some(cp) = load_checkpoint() {
|
||
if !cp.findings.is_empty() {
|
||
let wd = std::path::PathBuf::from(&cp.workdir);
|
||
std::fs::create_dir_all(&wd).ok();
|
||
crate::report_raw(&cp.target, &cp.findings, &wd); // materialize a report so /report works
|
||
let mut h = history.lock().unwrap();
|
||
let id = h.len() + 1;
|
||
h.push(RunRecord { id, mode: cp.mode.clone(), target: cp.target.clone(), workdir: cp.workdir.clone(), findings: cp.findings.clone() });
|
||
save_runs(base, &h);
|
||
println!(" \x1b[1;33m↻ recovered interrupted run on {} — {} finding(s) saved as run #{}\x1b[0m (/results {id} · /report {id})",
|
||
cp.target, cp.findings.len(), id);
|
||
}
|
||
clear_checkpoint();
|
||
}
|
||
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 };
|
||
// 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;
|
||
}
|
||
if !line.starts_with('/') {
|
||
let attached = expand_ats(line, &mut s);
|
||
s.instructions = Some(line.to_string());
|
||
println!(" focus set: {line}");
|
||
if attached > 0 { println!(" ({attached} @attachment(s) added to context)"); }
|
||
continue;
|
||
}
|
||
let mut parts = line.splitn(2, char::is_whitespace);
|
||
let cmd = parts.next().unwrap_or("");
|
||
let arg = parts.next().unwrap_or("").trim();
|
||
match cmd {
|
||
"/help" | "/?" => help(),
|
||
"/show" | "/config" => show(&s),
|
||
"/providers" => {
|
||
for p in harness::providers() {
|
||
println!(" [{}] {:<14} {}", p.kind, p.key,
|
||
p.models.iter().map(|m| format!("{}:{}", p.key, m)).collect::<Vec<_>>().join(" "));
|
||
}
|
||
}
|
||
"/model" | "/models" => {
|
||
if arg.is_empty() {
|
||
pick_models(&mut s);
|
||
} else {
|
||
s.models = arg.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect();
|
||
println!(" models: {}", s.models.join(", "));
|
||
}
|
||
// If a run is paused on exhaustion, queue the newly-chosen models
|
||
// as its fallback so a plain /continue picks them up.
|
||
if let Some(a) = &active {
|
||
if a.paused.load(Ordering::Relaxed) {
|
||
let mut fb = a.fallback.lock().unwrap();
|
||
for id in &s.models { fb.push(ModelRef::parse(id)); }
|
||
println!(" \x1b[2m↪ queued for the paused run — /continue to resume on these model(s)\x1b[0m");
|
||
}
|
||
}
|
||
}
|
||
"/key" => key_cmd(&mut s, arg, &mut reader),
|
||
"/sub" | "/subscription" => {
|
||
s.subscription = !matches!(arg, "off" | "false" | "0" | "no");
|
||
println!(" subscription: {}", onoff(s.subscription));
|
||
}
|
||
"/target" | "/url" => {
|
||
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 {
|
||
// 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"); }
|
||
}
|
||
}
|
||
"/ua" | "/useragent" => {
|
||
match arg {
|
||
"" => println!(" user-agent: {} \x1b[2m(identifies NeuroSploit traffic)\x1b[0m",
|
||
s.user_agent.clone().unwrap_or_else(harness::pipeline::default_user_agent)),
|
||
"default" | "reset" => { s.user_agent = None; println!(" user-agent reset to default (NeuroSploit)"); }
|
||
u => { s.user_agent = Some(u.to_string()); println!(" user-agent: {u}"); }
|
||
}
|
||
}
|
||
"/proxy" | "/burp" => {
|
||
match arg {
|
||
"" => println!(" proxy: {}", s.proxy.clone().unwrap_or_else(|| "(none) — route traffic to Burp/ZAP with /proxy <url>, e.g. /proxy http://127.0.0.1:8080".into())),
|
||
"off" | "clear" | "none" => { s.proxy = None; println!(" proxy cleared — traffic goes direct"); }
|
||
"on" => { s.proxy = Some("http://127.0.0.1:8080".into()); println!(" proxy: http://127.0.0.1:8080 (default Burp) — agents route curl through it"); }
|
||
u => { let p = if u.starts_with("http") { u.to_string() } else { format!("http://{u}") };
|
||
s.proxy = Some(p.clone()); println!(" proxy: {p} — agents route HTTP through it so you can inspect/replay in Burp"); }
|
||
}
|
||
}
|
||
"/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())); }
|
||
else if arg == "clear" { s.repo = None; println!(" repo cleared"); }
|
||
else {
|
||
// Accept a local path OR a GitHub URL / owner-repo shorthand (cloned on set).
|
||
match crate::resolve_source(base, arg) {
|
||
Ok(p) => { s.repo = Some(p.clone()); println!(" repo: {p}"); }
|
||
Err(e) => println!(" \x1b[31mcould not resolve repo: {e}\x1b[0m"),
|
||
}
|
||
}
|
||
}
|
||
"/auth" => {
|
||
if arg.is_empty() {
|
||
match s.auth.clone() {
|
||
Some(a) => println!(" auth: {a}"),
|
||
None => println!(" auth: (none) — /auth <header> · or roles: /auth admin <hdr> · /auth user <hdr>"),
|
||
}
|
||
for (n, v) in &s.roles { println!(" role {n}: {v}"); }
|
||
if s.roles.len() >= 2 { println!(" \x1b[2m{} identities → access-control testing (IDOR/BOLA/BFLA) on /run\x1b[0m", s.roles.len()); }
|
||
}
|
||
else if arg == "clear" { s.auth = None; s.roles.clear(); println!(" auth + roles cleared"); }
|
||
else {
|
||
// "<role> <value>" if the first token is a bare identifier (no ':').
|
||
let mut it = arg.splitn(2, char::is_whitespace);
|
||
let first = it.next().unwrap_or("");
|
||
let rest = it.next().unwrap_or("").trim();
|
||
let is_role = !first.contains(':') && !rest.is_empty()
|
||
&& first.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
|
||
if is_role {
|
||
let val = normalize_auth(rest);
|
||
s.roles.retain(|(n, _)| n != first);
|
||
s.roles.push((first.to_string(), val.clone()));
|
||
if s.auth.is_none() { s.auth = Some(val); } // first role also = primary session
|
||
println!(" role '{first}' set ({} identit{}) — test both scenarios on /run", s.roles.len(), if s.roles.len() == 1 { "y" } else { "ies" });
|
||
} else {
|
||
s.auth = Some(arg.to_string()); println!(" auth set: {arg}");
|
||
}
|
||
}
|
||
}
|
||
"/creds" => {
|
||
if arg.is_empty() { println!(" creds file: {}", s.creds.clone().unwrap_or_else(|| "(none) — set with /creds <file.yaml>".into())); }
|
||
else if arg == "clear" { s.creds = None; println!(" creds cleared"); }
|
||
else { s.creds = Some(arg.to_string()); println!(" creds file: {arg}"); }
|
||
}
|
||
"/focus" | "/instructions" => {
|
||
if arg == "clear" { s.instructions = None; println!(" focus cleared"); continue; }
|
||
if arg.is_empty() { println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into())); continue; }
|
||
s.instructions = Some(arg.to_string());
|
||
println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into()));
|
||
}
|
||
"/attach" => { let n = attach_path(arg.trim_start_matches('@'), &mut s); if n > 0 { println!(" attached ({} total)", s.attachments.len()); } }
|
||
"/context" => {
|
||
if s.attachments.is_empty() { println!(" no attachments — add with @path or /attach <path>"); }
|
||
else { println!(" context attachments ({}):", s.attachments.len());
|
||
for a in &s.attachments { println!(" • {}", a.lines().next().unwrap_or("").trim_start_matches("// ")); } }
|
||
}
|
||
"/theme" => {
|
||
s.color = !matches!(arg, "off" | "mono" | "no-color" | "plain");
|
||
println!(" theme: {}", if s.color { "color" } else { "mono" });
|
||
}
|
||
"/mcp" => { s.mcp = !matches!(arg, "off" | "false" | "0" | "no"); println!(" Playwright MCP: {}", onoff(s.mcp)); }
|
||
"/offline" => { s.offline = !matches!(arg, "off" | "false" | "0" | "no"); println!(" offline: {}", onoff(s.offline)); }
|
||
"/integrations" | "/integration" => integrations_cmd(arg),
|
||
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
|
||
"/chain" => {
|
||
if arg.is_empty() { println!(" attack-chain depth: {} (0 disables) — set with /chain <n>", s.chain_depth); }
|
||
else { s.chain_depth = arg.parse().unwrap_or(s.chain_depth); println!(" attack-chain depth: {}", s.chain_depth); }
|
||
}
|
||
"/agents" => {
|
||
if arg == "list" || arg == "ls" {
|
||
let lib = agents::load(base);
|
||
println!(" agent library ({} total):", lib.total());
|
||
println!(" vulns {} · code {} · infra/cloud {} · recon {} · chains {} · meta {}",
|
||
lib.vulns.len(), lib.code.len(), lib.infra.len(), lib.recon.len(), lib.chains.len(), lib.meta.len());
|
||
} else if arg.is_empty() {
|
||
println!(" max agents: {} (0 = all) — set with /agents <n>, or /agents list for counts", s.max_agents);
|
||
} else {
|
||
s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {}", s.max_agents);
|
||
}
|
||
}
|
||
"/clear" => { print!("\x1b[2J\x1b[H"); }
|
||
"/run" | "/go" => {
|
||
if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) {
|
||
println!(" a run is already active — /status to check, /stop to halt it.");
|
||
} else {
|
||
save_session(&s);
|
||
// 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();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"/stop" => {
|
||
match &active {
|
||
Some(a) if !a.done.load(Ordering::Relaxed) => {
|
||
println!(" \x1b[1mStop the run — choose:\x1b[0m");
|
||
println!(" \x1b[36m1\x1b[0m validate the findings found so far, then report \x1b[2m(recommended)\x1b[0m");
|
||
println!(" \x1b[36m2\x1b[0m report NOW without validating (raw findings)");
|
||
println!(" \x1b[36m3\x1b[0m discard (no report)");
|
||
let ans = ask_line(" choice [1/2/3]:");
|
||
match ans.trim() {
|
||
"2" => { *a.choice.lock().unwrap() = StopMode::Raw; a.cancel.store(true, Ordering::Relaxed);
|
||
println!(" ⏹ stopping — generating a RAW report from what was found…"); }
|
||
"3" => { *a.choice.lock().unwrap() = StopMode::Discard; a.cancel.store(true, Ordering::Relaxed);
|
||
println!(" 🗑 stopping — discarding this run."); }
|
||
_ => { *a.choice.lock().unwrap() = StopMode::Validate; a.soft.store(true, Ordering::Relaxed);
|
||
println!(" ⏸ stopping exploitation — validating what was found, then reporting…"); }
|
||
}
|
||
}
|
||
_ => println!(" no active run."),
|
||
}
|
||
}
|
||
"/continue" | "/resume" => {
|
||
match &active {
|
||
Some(a) if a.paused.load(Ordering::Relaxed) => {
|
||
if !arg.is_empty() {
|
||
let m = ModelRef::parse(arg);
|
||
println!(" \x1b[1;35m▶ resuming with fallback model\x1b[0m {}:{}", m.provider, m.model);
|
||
a.fallback.lock().unwrap().push(m);
|
||
} else {
|
||
println!(" \x1b[1;35m▶ resuming\x1b[0m — retrying with the current model(s).");
|
||
}
|
||
a.paused.store(false, Ordering::Relaxed);
|
||
a.resume.notify_waiters();
|
||
}
|
||
Some(a) if !a.done.load(Ordering::Relaxed) => println!(" run is not paused — it's still working. /status to check."),
|
||
_ => println!(" no paused run. (a run pauses automatically if your tokens/quota run out)"),
|
||
}
|
||
}
|
||
"/runs" | "/history" => list_runs(&history.lock().unwrap()),
|
||
"/diff" | "/changed" => diff_runs(&history.lock().unwrap()),
|
||
"/retest" => {
|
||
let h = history.lock().unwrap();
|
||
if let Some(r) = pick(&h, arg) {
|
||
if r.target.starts_with('/') { s.repo = Some(r.target.clone()); s.target = None; }
|
||
else { s.target = Some(r.target.clone()); }
|
||
let titles: Vec<String> = r.findings.iter().map(|f| f.title.clone()).collect();
|
||
if !titles.is_empty() {
|
||
s.instructions = Some(format!("RETEST — re-verify whether these prior findings are now fixed: {}", titles.join("; ")));
|
||
}
|
||
println!(" ↻ retest set up for {} ({} prior finding(s)) — /run to launch", r.target, titles.len());
|
||
}
|
||
}
|
||
"/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; }
|
||
}
|
||
};
|
||
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" => {
|
||
// Build the finding pool: live run if active, else a past run.
|
||
let pool: Vec<Finding> = match &active {
|
||
Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => a.live.lock().unwrap().full.clone(),
|
||
_ => { let h = history.lock().unwrap(); pick(&h, arg).map(|r| r.findings.clone()).unwrap_or_default() }
|
||
};
|
||
finding_detail(&pool);
|
||
}
|
||
"/expand" | "/full" => {
|
||
// Show full untruncated commands from the active run.
|
||
match &active {
|
||
Some(a) => {
|
||
let l = a.live.lock().unwrap();
|
||
let n: usize = arg.trim().parse().unwrap_or(5);
|
||
let cmds = &l.commands;
|
||
if cmds.is_empty() { println!(" no commands captured yet."); }
|
||
else {
|
||
println!(" ── last {} command(s) (full) ──", n.min(cmds.len()));
|
||
for c in cmds.iter().rev().take(n).rev() { println!(" \x1b[33m$ {c}\x1b[0m"); }
|
||
}
|
||
}
|
||
None => println!(" no active run — /expand shows full commands while a run streams."),
|
||
}
|
||
}
|
||
"/report" => open_report(&history.lock().unwrap(), arg),
|
||
"/status" => {
|
||
// Live status if a run is active, else a past run's status.json.
|
||
match &active {
|
||
Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => {
|
||
let l = a.live.lock().unwrap();
|
||
let el = l.started.elapsed().as_secs();
|
||
let mut by: std::collections::BTreeMap<&str, usize> = Default::default();
|
||
for (sv, _) in &l.findings { *by.entry(sv.as_str()).or_insert(0) += 1; }
|
||
let sev = if by.is_empty() { "0".into() } else { by.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(" ") };
|
||
println!(" \x1b[1m▶ live\x1b[0m {} ({}) · phase {} · {:02}:{:02} · {} possible finding(s) [{}]",
|
||
l.target, l.mode, l.phase, el / 60, el % 60, l.findings.len(), sev);
|
||
if a.paused.load(Ordering::Relaxed) {
|
||
println!(" \x1b[1;33m⏸ PAUSED — token/quota exhausted. /continue to resume, or /model <provider:model> then /continue to switch.\x1b[0m");
|
||
}
|
||
if l.agents > 0 { println!(" progress \x1b[36m{}\x1b[0m", l.bar(24)); }
|
||
for (sv, t) in l.findings.iter().rev().take(5) { println!(" ✦ [{sv}] {t}"); }
|
||
}
|
||
_ => run_status(&history.lock().unwrap(), arg),
|
||
}
|
||
}
|
||
"/quit" | "/exit" | "/q" => {
|
||
if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) {
|
||
if let Some(a) = &active { a.cancel.store(true, Ordering::Relaxed); }
|
||
println!(" ⏸ a run is active — requested stop; quitting.");
|
||
}
|
||
save_session(&s); println!(" session saved → {} · bye.", proj_dir().display()); break;
|
||
}
|
||
other => println!(" unknown command '{other}' — try /help"),
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Arrow-key multi-select of models from the catalog (interactive terminals only).
|
||
fn pick_models(s: &mut Session) {
|
||
if !std::io::stdin().is_terminal() {
|
||
println!(" current: {} (use /model <provider:model,...> to set)", s.models.join(", "));
|
||
return;
|
||
}
|
||
let mut ids: Vec<String> = Vec::new();
|
||
for p in harness::providers() {
|
||
for m in &p.models {
|
||
ids.push(format!("{}:{}", p.key, m));
|
||
}
|
||
}
|
||
let defaults: Vec<bool> = ids.iter().map(|id| s.models.contains(id)).collect();
|
||
match MultiSelect::with_theme(&ColorfulTheme::default())
|
||
.with_prompt("Select models (space toggles, ↑/↓ moves, enter confirms)")
|
||
.items(&ids)
|
||
.defaults(&defaults)
|
||
.interact_opt()
|
||
{
|
||
Ok(Some(idx)) if !idx.is_empty() => {
|
||
s.models = idx.into_iter().map(|i| ids[i].clone()).collect();
|
||
println!(" models: {}", s.models.join(", "));
|
||
}
|
||
_ => println!(" models unchanged: {}", s.models.join(", ")),
|
||
}
|
||
}
|
||
|
||
/// Configure API keys based on the selected models: `/key` lists the providers
|
||
/// your models need (set/missing) and prompts for missing ones; `/key <prov> <key>`
|
||
/// sets one directly.
|
||
fn key_cmd(s: &mut Session, arg: &str, reader: &mut Reader) {
|
||
if !arg.is_empty() {
|
||
let mut kp = arg.splitn(2, char::is_whitespace);
|
||
if let (Some(prov), Some(key)) = (kp.next(), kp.next()) {
|
||
set_key(prov, key.trim(), s);
|
||
} else {
|
||
println!(" usage: /key <provider> <api-key> e.g. /key anthropic sk-ant-...");
|
||
}
|
||
return;
|
||
}
|
||
// No arg → walk the providers required by the selected models.
|
||
let provs: Vec<String> = s.models.iter()
|
||
.map(|m| m.split(':').next().unwrap_or("").to_string())
|
||
.collect::<std::collections::BTreeSet<_>>().into_iter().collect();
|
||
println!(" API keys for your selected models:");
|
||
for prov in &provs {
|
||
let Some(p) = harness::provider_for(prov) else { continue };
|
||
let set = std::env::var(p.env_key).map(|v| !v.is_empty()).unwrap_or(false);
|
||
let mark = if set { "✓ set" } else { "✗ missing" };
|
||
println!(" {prov:<12} {} ({})", mark, p.env_key);
|
||
}
|
||
if std::io::stdin().is_terminal() {
|
||
for prov in &provs {
|
||
let Some(p) = harness::provider_for(prov) else { continue };
|
||
if std::env::var(p.env_key).map(|v| !v.is_empty()).unwrap_or(false) {
|
||
continue;
|
||
}
|
||
if let Reader::Rl(ed, _) = reader {
|
||
match ed.readline(&format!(" paste {prov} key (blank to skip): ")) {
|
||
Ok(k) if !k.trim().is_empty() => set_key(prov, k.trim(), s),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
println!(" (set with /key <provider> <key> or export {{ENV}} before launch)");
|
||
}
|
||
}
|
||
|
||
fn set_key(prov: &str, key: &str, s: &mut Session) {
|
||
match harness::provider_for(prov) {
|
||
Some(p) => {
|
||
std::env::set_var(p.env_key, key);
|
||
s.subscription = false;
|
||
println!(" set {} (API mode)", p.env_key);
|
||
}
|
||
None => println!(" unknown provider '{prov}' (see /providers)"),
|
||
}
|
||
}
|
||
|
||
async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
|
||
enum M { Black(String), White(String), Grey { url: String, repo: String } }
|
||
let m = match (&s.repo, &s.target) {
|
||
(Some(r), Some(t)) => M::Grey { url: t.clone(), repo: r.clone() },
|
||
(Some(r), None) => M::White(r.clone()),
|
||
(None, Some(t)) => M::Black(t.clone()),
|
||
_ => { println!(" \x1b[31m✗ set a /target <url> and/or /repo <path> first.\x1b[0m"); return; }
|
||
};
|
||
let primary = match &m {
|
||
M::Black(t) | M::White(t) => t.clone(),
|
||
M::Grey { url, .. } => url.clone(),
|
||
};
|
||
let mut cfg = RunConfig::new(&primary);
|
||
cfg.models = s.models.clone();
|
||
cfg.subscription = s.subscription;
|
||
cfg.vote_n = s.vote_n;
|
||
cfg.chain_depth = s.chain_depth;
|
||
cfg.proxy = s.proxy.clone();
|
||
cfg.user_agent = s.user_agent.clone();
|
||
cfg.max_agents = s.max_agents;
|
||
cfg.verbose = true;
|
||
cfg.offline = s.offline;
|
||
// Fold @attachments (scope files / stack traces) into the instruction context.
|
||
cfg.instructions = match (s.instructions.clone(), s.attachments.is_empty()) {
|
||
(instr, true) => instr,
|
||
(instr, false) => {
|
||
let ctx = s.attachments.join("\n\n");
|
||
Some(format!("{}\n\nATTACHED CONTEXT:\n{ctx}", instr.unwrap_or_default()))
|
||
}
|
||
};
|
||
cfg.auth = s.auth.clone();
|
||
// Multiple /auth identities → prepend the access-control (IDOR/BOLA/BFLA) directive.
|
||
if let Some(rd) = roles_directive(&s.roles) {
|
||
let base = cfg.instructions.clone().unwrap_or_default();
|
||
cfg.instructions = Some(format!("{rd}{base}"));
|
||
}
|
||
if let M::Grey { repo, .. } = &m {
|
||
cfg.repo = Some(repo.clone());
|
||
}
|
||
crate::apply_creds(&mut cfg, s.creds.as_deref()).await;
|
||
|
||
let mode = match &m { M::Grey { .. } => "greybox", M::White(_) => "white-box", M::Black(_) => "black-box" };
|
||
let result = match m {
|
||
M::Grey { .. } => crate::run_greybox_engagement(base, cfg, s.mcp).await,
|
||
M::White(_) => crate::run_engagement(base, cfg, false, true).await,
|
||
M::Black(_) => crate::run_engagement(base, cfg, s.mcp, false).await,
|
||
};
|
||
match result {
|
||
Ok(out) => {
|
||
crate::print_findings(&out);
|
||
let id = history.len() + 1;
|
||
println!(" ↳ saved as run #{id} — /results {id} · /report {id} · /status {id}");
|
||
history.push(RunRecord { id, mode: mode.into(), target: primary, workdir: out.workdir.clone(), findings: out.findings.clone() });
|
||
}
|
||
Err(e) => println!(" \x1b[31m✗ run failed: {e}\x1b[0m"),
|
||
}
|
||
}
|
||
|
||
/// Launch an engagement in the BACKGROUND: it streams live via the editor's
|
||
/// 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>>>, 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;
|
||
cfg.vote_n = s.vote_n;
|
||
cfg.chain_depth = s.chain_depth;
|
||
cfg.proxy = s.proxy.clone();
|
||
cfg.user_agent = s.user_agent.clone();
|
||
cfg.max_agents = s.max_agents;
|
||
cfg.verbose = true;
|
||
cfg.offline = s.offline;
|
||
cfg.instructions = if s.attachments.is_empty() { s.instructions.clone() }
|
||
else { Some(format!("{}\n\nATTACHED CONTEXT:\n{}", s.instructions.clone().unwrap_or_default(), s.attachments.join("\n\n"))) };
|
||
cfg.auth = s.auth.clone();
|
||
if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); }
|
||
crate::apply_creds(&mut cfg, s.creds.as_deref()).await;
|
||
crate::subscription_preflight(&cfg).await; // warn early if the CLI isn't logged in
|
||
|
||
let mut printer = reader.external_printer()?; // None on piped stdin → blocking fallback
|
||
let sp = crate::spawn_engagement(base, cfg, mcp, mode_e);
|
||
|
||
let live = Arc::new(Mutex::new(RunLive {
|
||
target: target.clone(), mode: mode_s, phase: "starting".into(),
|
||
started: Instant::now(), findings: vec![], full: vec![], commands: vec![],
|
||
agents: 0, agents_done: 0,
|
||
}));
|
||
let cancel = sp.cancel.clone();
|
||
let soft = sp.soft.clone();
|
||
let paused = sp.paused.clone();
|
||
let resume = sp.resume.clone();
|
||
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 sub_mcp = s.subscription && mcp; // for the "browser/tools never engaged" diagnostic
|
||
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;
|
||
let mut last_find = Instant::now(); // time of the last NEW finding
|
||
let mut idle_fired = false;
|
||
let mut tool_events = 0usize; // exec/net/read/browser activity seen
|
||
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 line.contains("exec:") || line.contains("net:") || line.contains("read:") || line.contains("browser") { tool_events += 1; }
|
||
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();
|
||
|
||
// Diagnostic: subscription + MCP but the agents never ran a single tool/
|
||
// browser action → the CLI almost certainly isn't logged in (or the MCP
|
||
// didn't engage), which is why nothing was found.
|
||
if sub_mcp && tool_events == 0 {
|
||
let _ = printer.print("\x1b[1;33m[!] no browser/tool activity was observed this run — the subscription CLI is likely NOT logged in (run `claude` → /login) or the Playwright MCP didn't start. That's usually why a run finds 0.\x1b[0m".to_string());
|
||
}
|
||
|
||
if mode_choice == StopMode::Discard {
|
||
std::fs::remove_dir_all(&workdir).ok();
|
||
clear_checkpoint();
|
||
let _ = printer.print(format!("\x1b[33m🗑 run discarded — {}\x1b[0m", workdir.display()));
|
||
done2.store(true, Ordering::Relaxed);
|
||
return;
|
||
}
|
||
|
||
// Raw → report from the unvalidated candidates we captured live.
|
||
let (findings, validated_word) = if mode_choice == StopMode::Raw {
|
||
let raw = live2.lock().unwrap().full.clone();
|
||
crate::report_raw(&target, &raw, &workdir);
|
||
(raw, "unvalidated")
|
||
} else {
|
||
let out = crate::finalize_run(task_out, &workdir);
|
||
(out.findings, "validated")
|
||
};
|
||
|
||
let id = {
|
||
let mut h = hist2.lock().unwrap();
|
||
let id = h.len() + 1;
|
||
h.push(RunRecord { id, mode: mode_s.into(), target, workdir: workdir.display().to_string(), findings: findings.clone() });
|
||
if let Ok(j) = serde_json::to_string_pretty(&*h) { std::fs::write(proj_dir().join("runs.json"), j).ok(); }
|
||
id
|
||
};
|
||
clear_checkpoint(); // run is now a completed RunRecord
|
||
let _ = printer.print(format!(
|
||
"\x1b[1;32m◀ run #{id} done — {} {} finding(s)\x1b[0m · /results {id} · /finding",
|
||
findings.len(), validated_word));
|
||
let _ = printer.print(format!("\x1b[36m report: {}\x1b[0m", crate::report_url(&workdir)));
|
||
done2.store(true, Ordering::Relaxed);
|
||
});
|
||
Some(ActiveRun { live, cancel, soft, done, choice, paused, resume, fallback })
|
||
}
|
||
|
||
/// 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 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 Ok(j) = serde_json::to_string_pretty(history) { std::fs::write(p, j).ok(); }
|
||
}
|
||
|
||
/// Live-run checkpoint file (one in-flight run at a time).
|
||
fn checkpoint_path() -> std::path::PathBuf { proj_dir().join("active_run.json") }
|
||
fn save_checkpoint(c: &LiveCheckpoint) {
|
||
if let Ok(j) = serde_json::to_string_pretty(c) { std::fs::write(checkpoint_path(), j).ok(); }
|
||
}
|
||
fn clear_checkpoint() { std::fs::remove_file(checkpoint_path()).ok(); }
|
||
fn load_checkpoint() -> Option<LiveCheckpoint> {
|
||
std::fs::read_to_string(checkpoint_path()).ok().and_then(|t| serde_json::from_str(&t).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(); }
|
||
match arg.trim().parse::<usize>() {
|
||
Ok(n) => history.iter().find(|r| r.id == n).or_else(|| { println!(" no run #{n} (have 1..{})", history.len()); None }),
|
||
Err(_) => { println!(" usage: /results <run-number>"); None }
|
||
}
|
||
}
|
||
|
||
fn sev_counts(f: &[Finding]) -> std::collections::BTreeMap<&str, usize> {
|
||
let mut m = std::collections::BTreeMap::new();
|
||
for x in f { *m.entry(x.severity.as_str()).or_insert(0) += 1; }
|
||
m
|
||
}
|
||
|
||
fn list_runs(history: &[RunRecord]) {
|
||
if history.is_empty() { println!(" no runs yet."); return; }
|
||
println!(" ┌─ runs (this + past sessions)");
|
||
for r in history {
|
||
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(" ") };
|
||
println!(" │ #{:<2} {:<9} {:<38} {}", r.id, r.mode, trunc(&r.target, 38), sev);
|
||
}
|
||
println!(" └─ /results <n> · /report <n> · /status <n>");
|
||
}
|
||
|
||
fn results(history: &[RunRecord], arg: &str) {
|
||
let Some(r) = pick(history, arg) else { return };
|
||
println!(" ── run #{} ({}) — {} ──", r.id, r.mode, r.target);
|
||
if r.findings.is_empty() { println!(" (no validated findings)"); return; }
|
||
let mut f = r.findings.clone();
|
||
f.sort_by_key(|x| match x.severity.as_str() { "Critical" => 0, "High" => 1, "Medium" => 2, "Low" => 3, _ => 4 });
|
||
for x in &f {
|
||
println!(" • [{}] {}", x.severity, x.title);
|
||
println!(" {} · {} · votes {} · conf {:.2}", x.agent, x.cwe, x.votes, x.confidence);
|
||
if !x.endpoint.is_empty() { println!(" @ {}", x.endpoint); }
|
||
}
|
||
println!(" report: /report {}", r.id);
|
||
}
|
||
|
||
fn open_report(history: &[RunRecord], arg: &str) {
|
||
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") };
|
||
if !file.is_file() { println!(" no report file in {}", r.workdir); return; }
|
||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||
match std::process::Command::new(opener).arg(&file).spawn() {
|
||
Ok(_) => println!(" opening {}", file.display()),
|
||
Err(_) => println!(" report: {}", file.display()),
|
||
}
|
||
}
|
||
|
||
/// What changed between the last two runs (by finding title).
|
||
fn diff_runs(history: &[RunRecord]) {
|
||
if history.len() < 2 {
|
||
println!(" need at least 2 runs to diff (/runs).");
|
||
return;
|
||
}
|
||
let prev = &history[history.len() - 2];
|
||
let cur = &history[history.len() - 1];
|
||
let set = |r: &RunRecord| r.findings.iter().map(|f| f.title.clone()).collect::<std::collections::HashSet<_>>();
|
||
let (a, b) = (set(prev), set(cur));
|
||
println!(" ── what changed: run #{} → #{} ({} → {}) ──", prev.id, cur.id, prev.findings.len(), cur.findings.len());
|
||
for t in b.difference(&a) { println!(" \x1b[32m+ new\x1b[0m {t}"); }
|
||
for t in a.difference(&b) { println!(" \x1b[31m- gone\x1b[0m {t}"); }
|
||
if a == b { println!(" (no change in finding titles)"); }
|
||
}
|
||
|
||
fn sev_rank(s: &str) -> u8 {
|
||
match s { "Critical" => 0, "High" => 1, "Medium" => 2, "Low" => 3, _ => 4 }
|
||
}
|
||
|
||
/// Read one line synchronously (for the /stop choice prompt).
|
||
/// `/integrations` — show / enable / disable / setup GitHub, GitLab, Jira.
|
||
fn integrations_cmd(arg: &str) {
|
||
let dir = proj_dir();
|
||
let mut ig = harness::integrations::Integrations::load(&dir);
|
||
let mut parts = arg.splitn(2, char::is_whitespace);
|
||
let sub = parts.next().unwrap_or("").trim();
|
||
let name = parts.next().unwrap_or("").trim();
|
||
match sub {
|
||
"" | "show" | "status" => {
|
||
println!(" \x1b[1mintegrations\x1b[0m · {}", dir.display());
|
||
for l in ig.status_lines() { println!(" {l}"); }
|
||
println!(" \x1b[2m/integrations enable|disable <github|gitlab|jira> · /integrations setup <jira|gitlab|github>\x1b[0m");
|
||
println!(" \x1b[2mtokens come from env vars (never stored): GITHUB_TOKEN · GITLAB_TOKEN · JIRA_EMAIL + JIRA_API_TOKEN\x1b[0m");
|
||
}
|
||
"enable" | "disable" => {
|
||
let on = sub == "enable";
|
||
match name {
|
||
"github" => ig.github.enabled = on,
|
||
"gitlab" => ig.gitlab.enabled = on,
|
||
"jira" => ig.jira.enabled = on,
|
||
_ => { println!(" usage: /integrations {sub} <github|gitlab|jira>"); return; }
|
||
}
|
||
let _ = ig.save(&dir);
|
||
println!(" {name} {}", if on { "enabled ✓" } else { "disabled" });
|
||
}
|
||
"setup" => match name {
|
||
"jira" => {
|
||
let base = ask_line(" Jira base URL (https://your-org.atlassian.net):");
|
||
if !base.trim().is_empty() { ig.jira.base_url = base.trim().trim_end_matches('/').to_string(); }
|
||
let proj = ask_line(" Jira project key (e.g. SEC):");
|
||
if !proj.trim().is_empty() { ig.jira.project_key = proj.trim().to_string(); }
|
||
let it = ask_line(" Issue type [Bug]:");
|
||
if !it.trim().is_empty() { ig.jira.issue_type = it.trim().to_string(); }
|
||
ig.jira.enabled = true;
|
||
let _ = ig.save(&dir);
|
||
println!(" ✓ jira configured (project {}, {}). Now export {} and {} in your shell.",
|
||
ig.jira.project_key, ig.jira.base_url, ig.jira.email_env, ig.jira.token_env);
|
||
}
|
||
"gitlab" => {
|
||
let b = ask_line(" GitLab base [https://gitlab.com]:");
|
||
if !b.trim().is_empty() { ig.gitlab.base = b.trim().trim_end_matches('/').to_string(); }
|
||
ig.gitlab.enabled = true;
|
||
let _ = ig.save(&dir);
|
||
println!(" ✓ gitlab enabled (base {}). Export {} (PAT with read_repository).", ig.gitlab.base, ig.gitlab.token_env);
|
||
}
|
||
"github" => {
|
||
let a = ask_line(" GitHub API base [https://api.github.com] (change for GHE):");
|
||
if !a.trim().is_empty() { ig.github.api = a.trim().trim_end_matches('/').to_string(); }
|
||
ig.github.enabled = true;
|
||
let _ = ig.save(&dir);
|
||
println!(" ✓ github enabled (api {}). Export {} (PAT with repo scope).", ig.github.api, ig.github.token_env);
|
||
}
|
||
_ => println!(" usage: /integrations setup <jira|gitlab|github>"),
|
||
},
|
||
_ => println!(" usage: /integrations [show | enable <name> | disable <name> | setup <name>]"),
|
||
}
|
||
}
|
||
|
||
fn ask_line(prompt: &str) -> String {
|
||
use std::io::Write;
|
||
print!("{prompt} ");
|
||
std::io::stdout().flush().ok();
|
||
let mut s = String::new();
|
||
std::io::stdin().read_line(&mut s).ok();
|
||
s
|
||
}
|
||
|
||
/// Arrow-key selection menu over findings; prints EVERYTHING about the chosen one
|
||
/// (command/PoC, evidence, impact, remediation, votes, confidence).
|
||
fn finding_detail(pool: &[Finding]) {
|
||
if pool.is_empty() { println!(" no findings to inspect yet."); return; }
|
||
let mut f = pool.to_vec();
|
||
f.sort_by_key(|x| sev_rank(&x.severity));
|
||
let items: Vec<String> = f.iter().map(|x| format!("[{}] {} — {}", x.severity, x.title, x.cwe)).collect();
|
||
let idx = if std::io::stdin().is_terminal() {
|
||
match dialoguer::Select::with_theme(&ColorfulTheme::default())
|
||
.with_prompt("Select a finding (↑/↓, enter)").items(&items).default(0).interact_opt() {
|
||
Ok(Some(i)) => i, _ => return,
|
||
}
|
||
} else { 0 };
|
||
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);
|
||
println!(" │ agent : {}", x.agent);
|
||
println!(" │ endpoint : {}", x.endpoint);
|
||
println!(" │ votes/conf : {} · {:.2}", x.votes, x.confidence);
|
||
println!(" ├─ \x1b[33mPayload / PoC\x1b[0m");
|
||
for l in x.payload.lines() { println!(" │ {l}"); }
|
||
println!(" ├─ \x1b[36mEvidence (tool output)\x1b[0m");
|
||
for l in x.evidence.lines() { println!(" │ {l}"); }
|
||
println!(" ├─ Impact");
|
||
for l in x.impact.lines() { println!(" │ {l}"); }
|
||
println!(" ├─ Remediation");
|
||
for l in x.remediation.lines() { println!(" │ {l}"); }
|
||
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(" ") };
|
||
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 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!(" {} — 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).
|
||
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")) {
|
||
Ok(txt) => println!(" run #{}: {}", r.id, txt.trim()),
|
||
Err(_) => println!(" run #{}: no status.json ({})", r.id, r.workdir),
|
||
}
|
||
}
|
||
|
||
fn show(s: &Session) {
|
||
let mode = match (&s.repo, &s.target) {
|
||
(Some(_), Some(_)) => "greybox (code + live)",
|
||
(Some(_), None) => "white-box (code)",
|
||
(None, Some(_)) => "black-box (live)",
|
||
_ => "(set /target and/or /repo)",
|
||
};
|
||
println!(" ┌─ session");
|
||
println!(" │ models : {}", s.models.join(", "));
|
||
println!(" │ auth mode: {}", if s.subscription { "subscription (CLI login)" } else { "API key" });
|
||
println!(" │ mode : {mode}");
|
||
println!(" │ target : {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
||
println!(" │ repo : {}", s.repo.clone().unwrap_or_else(|| "(none)".into()));
|
||
println!(" │ auth : {}", s.auth.clone().unwrap_or_else(|| "(none)".into()));
|
||
println!(" │ creds : {}", s.creds.clone().unwrap_or_else(|| "(none)".into()));
|
||
println!(" │ proxy : {}", s.proxy.clone().unwrap_or_else(|| "(none — /proxy for Burp/ZAP)".into()));
|
||
println!(" │ user-agent: {}", s.user_agent.clone().unwrap_or_else(|| "NeuroSploit (default)".into()));
|
||
println!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into()));
|
||
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());
|
||
let on: Vec<&str> = [(ig.github.enabled, "github"), (ig.gitlab.enabled, "gitlab"), (ig.jira.enabled, "jira")]
|
||
.iter().filter(|(e, _)| *e).map(|(_, n)| *n).collect();
|
||
println!(" │ integr. : {}", if on.is_empty() { "(none — /integrations)".into() } else { on.join(", ") });
|
||
}
|
||
// API-key status for the providers your selected models need.
|
||
if !s.subscription {
|
||
let provs: std::collections::BTreeSet<String> = s.models.iter()
|
||
.map(|m| m.split(':').next().unwrap_or("").to_string()).collect();
|
||
let mut keys = Vec::new();
|
||
for p in &provs {
|
||
if let Some(pr) = harness::provider_for(p) {
|
||
let set = std::env::var(pr.env_key).map(|v| !v.is_empty()).unwrap_or(false);
|
||
keys.push(format!("{p}={}", if set { "✓" } else { "✗" }));
|
||
}
|
||
}
|
||
if !keys.is_empty() { println!(" │ api keys : {}", keys.join(" ")); }
|
||
}
|
||
println!(" └─ /run to launch · edit with /target /repo /auth /creds /focus /model");
|
||
}
|
||
|
||
fn help() {
|
||
let h = |c: &str, d: &str| println!(" \x1b[36m{c:<20}\x1b[0m {d}");
|
||
println!("\n \x1b[1mNeuroSploit REPL — commands\x1b[0m");
|
||
|
||
println!("\n \x1b[2mTARGET & SCOPE\x1b[0m");
|
||
h("/target <url[,..]>", "black-box target URL (comma-separated = multi-target, sequential)");
|
||
h("/repo <path|url>", "analyse a repo — path or GitHub URL (repo + target = greybox)");
|
||
h("/auth <value>", "auth header (Bearer/cookie/key). Roles: /auth admin <hdr> · /auth user <hdr>");
|
||
h("/creds <file.yaml>", "creds: jwt/header/cookie/login + ssh/windows + aws/gcp/azure + roles");
|
||
h("/focus <text>", "steer the tests (or just type the instruction)");
|
||
h("@path @dir @f:1-20", "attach a file/folder/line-range to context (Tab → menu)");
|
||
h("/attach <path>", "attach a file/folder to context");
|
||
h("/context", "list current attachments");
|
||
|
||
println!("\n \x1b[2mMODELS & AUTH\x1b[0m");
|
||
h("/model [a:b,..]", "set models (no arg → arrow-key multi-select)");
|
||
h("/providers", "list providers & models");
|
||
h("/key [prov key]", "configure API keys for your models (no arg → guided)");
|
||
h("/sub on|off", "use local subscription login instead of an API key");
|
||
|
||
println!("\n \x1b[2mRUN & MONITOR\x1b[0m");
|
||
h("/run", "launch (runs in the BACKGROUND — keep typing)");
|
||
h("/status [n]", "live progress + findings while running (or a past run #)");
|
||
h("/stop", "stop: [1] validate+report [2] raw report now [3] discard");
|
||
h("/continue", "resume a run paused on token/quota (change /model first to switch)");
|
||
h("/results [n]", "browse findings (target → vuln → detail; Esc = back)");
|
||
h("/finding [n]", "pick a finding and see its command + PoC + evidence");
|
||
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 (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>");
|
||
|
||
println!("\n \x1b[2mOPTIONS\x1b[0m");
|
||
h("/mcp on|off", "Playwright MCP browser (prove client-side issues)");
|
||
h("/offline on|off", "pipeline self-test (no API keys / no model calls)");
|
||
h("/votes <n>", "number of validator votes per finding");
|
||
h("/chain <n>", "attack-chain depth (post-exploitation pivots; 0 = off)");
|
||
h("/timeout <min>", "idle guardrail: stop if no new finding in <min> (0 = off)");
|
||
h("/proxy <url>|off", "route agent HTTP through Burp/ZAP (/burp = default :8080)");
|
||
h("/ua <string>", "identifying User-Agent for NeuroSploit traffic (default = NeuroSploit)");
|
||
h("/agents <n>|list", "cap agents to run · `list` shows library counts");
|
||
h("/theme color|mono", "toggle colored output");
|
||
h("/show", "show the current session config");
|
||
h("/clear", "clear the screen");
|
||
h("/quit", "save session and exit");
|
||
|
||
println!("\n \x1b[2mMODES — black-box: set /target · white-box: set /repo · grey-box: set BOTH /repo + /target · host: /target <ip> + /creds\x1b[0m");
|
||
println!(" \x1b[2mFindings are checkpointed live to .neurosploit/ — quit/crash mid-run and they're recovered into /runs next launch.\x1b[0m");
|
||
println!(" \x1b[2mIf tokens/quota run out the run PAUSES (state kept) — /continue to resume, or switch with /model then /continue.\x1b[0m");
|
||
println!(" \x1b[2m↑/↓ history · Tab completes commands & @paths · Ctrl-A/E/K edit · Ctrl-O full cmd · \\ for multiline\x1b[0m\n");
|
||
}
|
||
|
||
/// Scan a line for @path tokens, attach each referenced file/dir to context.
|
||
fn expand_ats(line: &str, s: &mut Session) -> usize {
|
||
let mut n = 0;
|
||
for tok in line.split_whitespace() {
|
||
if let Some(p) = tok.strip_prefix('@') {
|
||
n += attach_path(p, s);
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
/// Attach a file's content (capped) or a directory listing to session context.
|
||
/// Supports @file, @folder, and @file:LINE / @file:START-END.
|
||
fn attach_path(spec: &str, s: &mut Session) -> usize {
|
||
if spec.is_empty() { return 0; }
|
||
let (path, range) = match spec.split_once(':') {
|
||
Some((p, r)) => (p, Some(r)),
|
||
None => (spec, None),
|
||
};
|
||
let pb = Path::new(path);
|
||
if pb.is_dir() {
|
||
let mut items: Vec<String> = std::fs::read_dir(pb).map(|rd| rd.flatten()
|
||
.map(|e| e.file_name().to_string_lossy().to_string()).collect()).unwrap_or_default();
|
||
items.sort();
|
||
s.attachments.push(format!("// dir {path}:\n{}", items.join("\n")));
|
||
println!(" + folder {path} ({} entries)", items.len());
|
||
return 1;
|
||
}
|
||
match std::fs::read_to_string(pb) {
|
||
Ok(content) => {
|
||
let body = match range.and_then(parse_range) {
|
||
Some((a, b)) => content.lines().enumerate()
|
||
.filter(|(i, _)| *i + 1 >= a && *i + 1 <= b)
|
||
.map(|(_, l)| l).collect::<Vec<_>>().join("\n"),
|
||
None => content.chars().take(8000).collect(),
|
||
};
|
||
println!(" + file {spec} ({} bytes)", body.len());
|
||
s.attachments.push(format!("// file {spec}:\n{body}"));
|
||
1
|
||
}
|
||
Err(_) => { println!(" \x1b[31m✗ cannot read @{spec}\x1b[0m"); 0 }
|
||
}
|
||
}
|
||
|
||
fn parse_range(r: &str) -> Option<(usize, usize)> {
|
||
match r.split_once('-') {
|
||
Some((a, b)) => Some((a.trim().parse().ok()?, b.trim().parse().ok()?)),
|
||
None => { let n: usize = r.trim().parse().ok()?; Some((n, n)) }
|
||
}
|
||
}
|
||
|
||
/// Context/status bar shown above the prompt — model · cwd · mode/target,
|
||
/// e.g. "claude-opus-4-8 · /opt/projeto · black-box▸target".
|
||
fn context_prompt(s: &Session) -> String {
|
||
let model = s.models.first().map(|m| m.split(':').next_back().unwrap_or(m)).unwrap_or("?");
|
||
let auth = if s.subscription { "sub" } else { "api" };
|
||
let cwd = std::env::current_dir().ok()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|| ".".into());
|
||
let mode = match (&s.repo, &s.target) {
|
||
(Some(_), Some(_)) => "greybox",
|
||
(Some(_), None) => "white-box",
|
||
(None, Some(_)) => "black-box",
|
||
_ => "idle",
|
||
};
|
||
let tgt = s.target.clone().or_else(|| s.repo.clone()).unwrap_or_default();
|
||
let tgt = if tgt.is_empty() { String::new() } else { format!("▸{}", tgt.replace("https://", "").replace("http://", "")) };
|
||
// Dim context line, printed ABOVE the prompt (not part of the readline prompt,
|
||
// so its ANSI/newline never corrupts rustyline's cursor math).
|
||
format!("\x1b[2m{model} {auth} · {cwd} · {mode}{tgt}\x1b[0m")
|
||
}
|
||
|
||
/// The actual readline prompt — plain text so rustyline measures its width
|
||
/// 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__";
|
||
|
||
/// Turn a role value into a header line: a full `Header: value` is used as-is;
|
||
/// a bare token becomes `Authorization: Bearer <token>`.
|
||
fn normalize_auth(v: &str) -> String {
|
||
let v = v.trim();
|
||
if v.contains(':') { v.to_string() } else { format!("Authorization: Bearer {v}") }
|
||
}
|
||
|
||
/// Build the multi-role access-control directive from the session roles (mirrors
|
||
/// creds.yaml roles). Empty when fewer than 2 identities.
|
||
fn roles_directive(roles: &[(String, String)]) -> Option<String> {
|
||
if roles.len() < 2 { return None; }
|
||
let list = roles.iter().map(|(n, v)| format!(" - {n} → send `{v}`")).collect::<Vec<_>>().join("\n");
|
||
Some(format!(
|
||
"MULTI-ROLE ACCESS CONTROL — you have {} identities:\n{list}\n\
|
||
Authenticate as EACH identity (send its header on every request). Test broken access control ACROSS roles and \
|
||
compare authorized vs unauthorized:\n\
|
||
- BOLA/IDOR: as a low-privilege role capture your own object IDs, then read/modify another role's objects by ID.\n\
|
||
- BFLA: call admin-only functions/endpoints/HTTP methods with a low-privilege role's session.\n\
|
||
- Privilege escalation: mass-assignment of role/permission fields, or reaching admin routes.\n\
|
||
Prove each with the two requests (authorized role succeeds, unauthorized role should be denied but isn't). \
|
||
Read-only proof; mask any PII.\n\n",
|
||
roles.len()))
|
||
}
|
||
|
||
/// 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() }
|
||
else { format!("{}…", s.chars().take(n.saturating_sub(1)).collect::<String>()) }
|
||
}
|