From f21b96e8c1e1728b6dbcb21d786ed24c6e664cdb Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Wed, 24 Jun 2026 20:21:35 -0300 Subject: [PATCH] =?UTF-8?q?v3.5.0:=20complete=20REPL=20=E2=80=94=20run=20h?= =?UTF-8?q?istory,=20/results,=20/report,=20/status,=20/offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- neurosploit-rs/app/src/repl.rs | 131 +++++++++++++++++- neurosploit-rs/crates/harness/src/pipeline.rs | 9 +- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 6ba8ed3..a63217d 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -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, +} + /// Mutable session state edited via slash-commands and consumed by `/run`. struct Session { models: Vec, @@ -17,6 +26,7 @@ struct Session { mcp: bool, vote_n: usize, max_agents: usize, + offline: bool, target: Option, repo: Option, auth: Option, @@ -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 = 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) { // 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::() { + 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::>().join(" ") }; + println!(" │ #{:<2} {:<9} {:<40} {}", r.id, r.mode, trunc(&r.target, 40), sev); + } + println!(" └─ /results · /report · /status "); +} + +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 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 validator votes per finding"); println!(" /agents 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:"); diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index f9cb765..d04ab36 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -16,6 +16,8 @@ pub struct RunOutput { pub agents_ran: Vec, pub candidates: usize, pub recon: String, + /// The run's output directory (runs/ns--/). + pub workdir: String, /// Paths to persisted artifacts (recon/exploit/findings/report), if any. pub artifacts: Vec, } @@ -112,7 +114,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender)> = 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 = 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(),