mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-06-30 07:15:30 +02:00
v3.5.0: complete REPL — run history, /results, /report, /status, /offline
- RunOutput exposes `workdir` so the session can locate reports.
- Session now records every run (RunRecord: id, mode, target, workdir, findings).
- New commands:
/runs list runs done this session (mode, target, severity counts)
/results [n] show findings of run n (default last), severity-sorted
/report [n] open the PDF/HTML report (open/xdg-open)
/status [n] print the run's status.json
/offline on|off pipeline self-test toggle (no model calls)
- Each /run prints "saved as run #n" with the quick commands.
- Verified offline: run → /runs → /results → /status all work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,19 @@
|
||||
//! steer which agents run and how — e.g. "find injection and broken access
|
||||
//! control". `/run` then executes the engagement with that configuration.
|
||||
|
||||
use harness::{agents, types::RunConfig};
|
||||
use harness::{agents, types::Finding, types::RunConfig};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// A run completed within this interactive session (for /runs, /results, /report).
|
||||
struct RunRecord {
|
||||
id: usize,
|
||||
mode: String,
|
||||
target: String,
|
||||
workdir: String,
|
||||
findings: Vec<Finding>,
|
||||
}
|
||||
|
||||
/// Mutable session state edited via slash-commands and consumed by `/run`.
|
||||
struct Session {
|
||||
models: Vec<String>,
|
||||
@@ -17,6 +26,7 @@ struct Session {
|
||||
mcp: bool,
|
||||
vote_n: usize,
|
||||
max_agents: usize,
|
||||
offline: bool,
|
||||
target: Option<String>,
|
||||
repo: Option<String>,
|
||||
auth: Option<String>,
|
||||
@@ -32,6 +42,7 @@ impl Default for Session {
|
||||
mcp: false,
|
||||
vote_n: 3,
|
||||
max_agents: 0,
|
||||
offline: false,
|
||||
target: None,
|
||||
repo: None,
|
||||
auth: None,
|
||||
@@ -58,6 +69,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
println!(" Type \x1b[36m/help\x1b[0m to get started, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit.\n");
|
||||
|
||||
let mut s = Session::default();
|
||||
let mut history: Vec<RunRecord> = Vec::new();
|
||||
show(&s);
|
||||
|
||||
let stdin = std::io::stdin();
|
||||
@@ -146,10 +158,18 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
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 (pipeline self-test): {}", onoff(s.offline));
|
||||
}
|
||||
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
|
||||
"/agents" => { s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {} ", s.max_agents); }
|
||||
"/clear" => { print!("\x1b[2J\x1b[H"); }
|
||||
"/run" | "/go" => run(base, &s).await,
|
||||
"/run" | "/go" => run(base, &s, &mut history).await,
|
||||
"/runs" | "/history" => list_runs(&history),
|
||||
"/results" => results(&history, arg),
|
||||
"/report" => open_report(&history, arg),
|
||||
"/status" => run_status(&history, arg),
|
||||
"/quit" | "/exit" | "/q" => { println!(" bye."); break; }
|
||||
other => println!(" unknown command '{other}' — try /help"),
|
||||
}
|
||||
@@ -157,7 +177,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(base: &Path, s: &Session) {
|
||||
async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
|
||||
// repo + target → greybox; repo only → whitebox; target only → black-box.
|
||||
enum M { Black(String), White(String), Grey { url: String, repo: String } }
|
||||
let m = match (&s.repo, &s.target) {
|
||||
@@ -179,6 +199,7 @@ async fn run(base: &Path, s: &Session) {
|
||||
cfg.vote_n = s.vote_n;
|
||||
cfg.max_agents = s.max_agents;
|
||||
cfg.verbose = true;
|
||||
cfg.offline = s.offline;
|
||||
cfg.instructions = s.instructions.clone();
|
||||
cfg.auth = s.auth.clone();
|
||||
if let M::Grey { repo, .. } = &m {
|
||||
@@ -186,17 +207,114 @@ async fn run(base: &Path, s: &Session) {
|
||||
}
|
||||
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),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a run by 1-based index argument; default = most recent.
|
||||
fn pick<'a>(history: &'a [RunRecord], arg: &str) -> Option<&'a RunRecord> {
|
||||
if history.is_empty() {
|
||||
println!(" no runs yet this session — /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: with a run number, e.g. /results 2"); 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 this session."); return; }
|
||||
println!(" ┌─ session runs");
|
||||
for r in history {
|
||||
let c = sev_counts(&r.findings);
|
||||
let sev = if c.is_empty() { "0 findings".to_string() }
|
||||
else { c.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(" ") };
|
||||
println!(" │ #{:<2} {:<9} {:<40} {}", r.id, r.mode, trunc(&r.target, 40), 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) {
|
||||
let Some(r) = pick(history, arg) else { return };
|
||||
let dir = Path::new(&r.workdir);
|
||||
let pdf = dir.join("report.pdf");
|
||||
let html = dir.join("report.html");
|
||||
let file = if pdf.is_file() { pdf } else { 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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_status(history: &[RunRecord], arg: &str) {
|
||||
let Some(r) = pick(history, arg) else { return };
|
||||
let sp = Path::new(&r.workdir).join("status.json");
|
||||
match std::fs::read_to_string(&sp) {
|
||||
Ok(txt) => println!(" run #{}: {}", r.id, txt.trim()),
|
||||
Err(_) => println!(" run #{}: no status.json ({})", r.id, r.workdir),
|
||||
}
|
||||
}
|
||||
|
||||
fn trunc(s: &str, n: usize) -> String {
|
||||
if s.len() <= n { s.to_string() } else { format!("{}…", &s[..n.saturating_sub(1)]) }
|
||||
}
|
||||
|
||||
fn show(s: &Session) {
|
||||
println!(" ┌─ session");
|
||||
println!(" │ models : {}", s.models.join(", "));
|
||||
@@ -230,10 +348,15 @@ fn help() {
|
||||
println!(" /focus <text> steer the tests, e.g. 'injection and broken access control'");
|
||||
println!(" (or just type the instruction with no slash)");
|
||||
println!(" /mcp on|off enable Playwright MCP browser (subscription path)");
|
||||
println!(" /offline on|off pipeline self-test (no model calls)");
|
||||
println!(" /votes <n> validator votes per finding");
|
||||
println!(" /agents <n> cap agents (0 = all matching)");
|
||||
println!(" /show show current session config");
|
||||
println!(" /run launch the engagement");
|
||||
println!(" /runs list runs done this session (history)");
|
||||
println!(" /results [n] show findings of run n (default: last)");
|
||||
println!(" /report [n] open the PDF/HTML report of run n");
|
||||
println!(" /status [n] show status.json of run n");
|
||||
println!(" /quit exit");
|
||||
println!();
|
||||
println!(" Example:");
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct RunOutput {
|
||||
pub agents_ran: Vec<String>,
|
||||
pub candidates: usize,
|
||||
pub recon: String,
|
||||
/// The run's output directory (runs/ns-<ts>-<target>/).
|
||||
pub workdir: String,
|
||||
/// Paths to persisted artifacts (recon/exploit/findings/report), if any.
|
||||
pub artifacts: Vec<String>,
|
||||
}
|
||||
@@ -112,7 +114,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let _ = tx.send(format!("selected {} specialist agents (RL-ranked)", selected.len())).await;
|
||||
let _ = tx.send("offline: no exploitation performed (provide API keys or --subscription to run live)".into()).await;
|
||||
let artifacts = persist(&cfg, &recon, "", &[]);
|
||||
return RunOutput { target: cfg.target.clone(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
}
|
||||
|
||||
// Use the model to pick the agents whose preconditions match the recon —
|
||||
@@ -218,7 +220,7 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
|
||||
|
||||
if cfg.offline || bytes == 0 {
|
||||
let artifacts = persist(&cfg, "{}", &context, &[]);
|
||||
return RunOutput { target: cfg.target.clone(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts };
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts };
|
||||
}
|
||||
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
@@ -326,7 +328,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
||||
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
|
||||
let _ = tx.send(format!("offline: selected {} agent(s); no live exploitation", selected.len())).await;
|
||||
let artifacts = persist(&cfg, &recon, &code_leads, &[]);
|
||||
return RunOutput { target: cfg.target.clone(), findings: vec![],
|
||||
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![],
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||
}
|
||||
|
||||
@@ -570,6 +572,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
|
||||
RunOutput {
|
||||
target: cfg.target.clone(),
|
||||
workdir: cfg.workdir.clone().unwrap_or_default(),
|
||||
candidates: findings.len(),
|
||||
findings,
|
||||
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
|
||||
|
||||
Reference in New Issue
Block a user