v3.5.0: orchestration chaining + rich REPL (rustyline, model arrow-select, persistent history) + model-aware /key

Harness:
- Exploit-chaining round: after validation, chain confirmed findings into deeper
  impact (SSRF→metadata, SQLi→dump→reuse, IDOR→ATO, file-read→secrets→RCE),
  validate the new findings, merge. Wired into black-box and greybox.
- Latest top models surfaced: claude-opus-4-8, gpt-5.1/gpt-5.1-codex, gemini-3-pro.

REPL:
- Real line editing via rustyline: ↑/↓ command-history recall, Ctrl-A/E/K, paste;
  Ctrl-C cancels the line, Ctrl-D exits. Command history persists to
  data/repl_history.txt. Graceful plain-stdin fallback when not a TTY.
- /model with no arg → arrow-key multi-select (dialoguer); with arg accepts any
  provider:model names.
- /key is model-aware: lists the providers your selected models need (set/missing)
  and prompts for the missing keys; /key <prov> <key> still works.
- Run history persists to data/repl_runs.json and reloads across sessions
  (/runs lists past + current; /results /report /status by run number).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 20:33:13 -03:00
parent f21b96e8c1
commit e8df48af9e
6 changed files with 485 additions and 139 deletions
+230 -5
View File
@@ -113,6 +113,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -159,12 +165,47 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
[[package]]
name = "dialoguer"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
dependencies = [
"console",
"shell-words",
"tempfile",
"thiserror 1.0.69",
"zeroize",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -176,6 +217,18 @@ dependencies = [
"syn",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "errno"
version = "0.3.14"
@@ -186,6 +239,29 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fd-lock"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -322,6 +398,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "http"
version = "1.4.2"
@@ -558,6 +643,12 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -608,8 +699,10 @@ version = "3.5.0"
dependencies = [
"anyhow",
"clap",
"dialoguer",
"futures",
"neurosploit-harness",
"rustyline",
"serde",
"serde_json",
"tokio",
@@ -629,6 +722,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -710,14 +824,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -738,7 +852,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -750,7 +864,7 @@ version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
@@ -773,6 +887,16 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -898,6 +1022,19 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
@@ -939,6 +1076,28 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustyline"
version = "14.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
dependencies = [
"bitflags",
"cfg-if",
"clipboard-win",
"fd-lock",
"home",
"libc",
"log",
"memchr",
"nix",
"radix_trie",
"unicode-segmentation",
"unicode-width 0.1.14",
"utf8parse",
"windows-sys 0.52.0",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1015,6 +1174,12 @@ dependencies = [
"serde",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shlex"
version = "2.0.1"
@@ -1102,13 +1267,46 @@ dependencies = [
"syn",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1261,6 +1459,24 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1433,6 +1649,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
+2
View File
@@ -16,3 +16,5 @@ tokio.workspace = true
anyhow.workspace = true
futures.workspace = true
clap = { version = "4", features = ["derive"] }
rustyline = "14"
dialoguer = "0.11"
+198 -129
View File
@@ -1,16 +1,20 @@
//! NeuroSploit v3.5.0 — interactive session (Claude-Code / Codex / Cursor-CLI style).
//!
//! Launched when `neurosploit` runs with no subcommand. A persistent REPL where
//! you pick models, set an API key (or use a subscription login), point at a URL
//! or a repo, configure authentication, and write free-text instructions that
//! steer which agents run and how — e.g. "find injection and broken access
//! control". `/run` then executes the engagement with that configuration.
//! 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, types::Finding, types::RunConfig};
use std::io::Write;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use serde::{Deserialize, Serialize};
use std::io::IsTerminal;
use std::path::Path;
/// A run completed within this interactive session (for /runs, /results, /report).
/// A run completed within this session (persisted to disk for /runs across sessions).
#[derive(Serialize, Deserialize, Clone)]
struct RunRecord {
id: usize,
mode: String,
@@ -19,7 +23,6 @@ struct RunRecord {
findings: Vec<Finding>,
}
/// Mutable session state edited via slash-commands and consumed by `/run`.
struct Session {
models: Vec<String>,
subscription: bool,
@@ -54,6 +57,54 @@ impl Default for Session {
const PROMPT: &str = "\x1b[35mneurosploit\x1b[0m ";
/// Line reader: full rustyline editing when interactive, plain stdin when piped.
enum Reader {
Rl(Box<DefaultEditor>, std::path::PathBuf),
Plain(std::io::Stdin),
}
impl Reader {
fn new(base: &Path) -> Reader {
if std::io::stdin().is_terminal() {
if let Ok(mut ed) = DefaultEditor::new() {
let hist = base.join("data").join("repl_history.txt");
std::fs::create_dir_all(hist.parent().unwrap()).ok();
let _ = ed.load_history(&hist);
return Reader::Rl(Box::new(ed), hist);
}
}
Reader::Plain(std::io::stdin())
}
/// Returns None to exit (EOF / Ctrl-D), Some(line) otherwise. Ctrl-C cancels
/// the current line (returns an empty string) instead of exiting.
fn read(&mut self) -> Option<String> {
match self {
Reader::Rl(ed, hist) => match ed.readline(PROMPT) {
Ok(l) => {
if !l.trim().is_empty() {
let _ = ed.add_history_entry(l.as_str());
let _ = ed.save_history(hist);
}
Some(l)
}
Err(ReadlineError::Interrupted) => Some(String::new()), // Ctrl-C: cancel line
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();
@@ -66,26 +117,22 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
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 get started, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit.\n");
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 mut history: Vec<RunRecord> = Vec::new();
let mut history: Vec<RunRecord> = load_runs(base);
if !history.is_empty() {
println!(" loaded {} past run(s) — /runs to list\n", history.len());
}
let mut reader = Reader::new(base);
show(&s);
let stdin = std::io::stdin();
loop {
print!("{PROMPT}");
std::io::stdout().flush().ok();
let mut line = String::new();
if stdin.read_line(&mut line).unwrap_or(0) == 0 {
println!();
break; // EOF (Ctrl-D)
}
let Some(line) = reader.read() else { println!("\n bye."); break };
let line = line.trim();
if line.is_empty() {
continue;
}
// A bare line that isn't a command is treated as test instructions.
if !line.starts_with('/') {
s.instructions = Some(line.to_string());
println!(" focus set: {line}");
@@ -105,35 +152,18 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
}
"/model" | "/models" => {
if arg.is_empty() {
println!(" current: {}", s.models.join(", "));
pick_models(&mut s);
} else {
s.models = arg.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect();
println!(" models: {}", s.models.join(", "));
}
}
"/key" => {
// /key <PROVIDER> <KEY> → sets the provider's env var for this session
let mut kp = arg.splitn(2, char::is_whitespace);
match (kp.next(), kp.next()) {
(Some(prov), Some(key)) if !key.trim().is_empty() => {
match harness::provider_for(prov) {
Some(p) => {
std::env::set_var(p.env_key, key.trim());
s.subscription = false;
println!(" set {} (API mode)", p.env_key);
}
None => println!(" unknown provider '{prov}' (see /providers)"),
}
}
_ => println!(" usage: /key <provider> <api-key> e.g. /key anthropic sk-ant-..."),
}
}
"/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" => {
// target + repo can coexist → greybox.
let t = if arg.starts_with("http") || arg.is_empty() { arg.to_string() } else { format!("https://{arg}") };
s.target = if t.is_empty() { None } else { Some(t) };
println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
@@ -154,18 +184,12 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
s.instructions = if arg.is_empty() { None } else { Some(arg.to_string()) };
println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into()));
}
"/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 (pipeline self-test): {}", onoff(s.offline));
}
"/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)); }
"/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); }
"/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, &mut history).await,
"/run" | "/go" => { run(base, &s, &mut history).await; save_runs(base, &history); }
"/runs" | "/history" => list_runs(&history),
"/results" => results(&history, arg),
"/report" => open_report(&history, arg),
@@ -177,17 +201,93 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
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>) {
// 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) {
(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;
}
_ => { 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(),
@@ -207,11 +307,7 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
}
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 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,
@@ -222,29 +318,32 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
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(),
});
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 runs_path(base: &Path) -> std::path::PathBuf {
base.join("data").join("repl_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 Some(dir) = p.parent() { std::fs::create_dir_all(dir).ok(); }
if let Ok(j) = serde_json::to_string_pretty(history) { std::fs::write(p, j).ok(); }
}
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();
}
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: with a run number, e.g. /results 2"); None }
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 }
}
}
@@ -255,13 +354,12 @@ fn sev_counts(f: &[Finding]) -> std::collections::BTreeMap<&str, usize> {
}
fn list_runs(history: &[RunRecord]) {
if history.is_empty() { println!(" no runs yet this session."); return; }
println!(" ┌─ session runs");
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".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);
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>");
}
@@ -269,14 +367,9 @@ fn list_runs(history: &[RunRecord]) {
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;
}
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,
});
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);
@@ -289,12 +382,8 @@ 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 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()),
@@ -304,68 +393,48 @@ fn open_report(history: &[RunRecord], arg: &str) {
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) {
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 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(", "));
println!(" │ auth mode: {}", if s.subscription { "subscription (CLI login)" } else { "API key" });
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!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into()));
println!("mcp : {} votes: {} max-agents: {}", onoff(s.mcp), s.vote_n, s.max_agents);
println!("opts : mcp={} offline={} votes={} max-agents={}", onoff(s.mcp), onoff(s.offline), s.vote_n, s.max_agents);
println!(" └─ /run to launch");
}
fn help() {
println!(" Commands:");
println!(" /model a:b[,c:d] set model panel (1st primary; rest fail over + vote)");
println!(" Commands (↑/↓ recall history · Ctrl-A/E/K edit · Ctrl-C cancels line):");
println!(" /model [a:b,..] set models; with no arg → arrow-key multi-select");
println!(" /providers list providers & models");
println!(" /key <prov> <key> set a provider API key (switches to API mode)");
println!(" /key [prov key] configure API keys for your models (no arg → guided)");
println!(" /sub on|off use local subscription login instead of API key");
println!(" /target <url> black-box target URL");
println!(" /repo <path> analyse a local repo (repo+target = greybox: code + live)");
println!(" /auth <value> auth to send (e.g. 'Authorization: Bearer <jwt>' or 'Cookie: s=..')");
println!(" /creds <file.yaml> load credentials (jwt/header/cookie/login) for authenticated tests");
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!(" /repo <path> analyse a repo (repo+target = greybox: code + live)");
println!(" /auth <value> auth header (e.g. 'Authorization: Bearer <jwt>')");
println!(" /creds <file.yaml> credentials (jwt/header/cookie/login) for authed tests");
println!(" /focus <text> steer the tests (or just type it); e.g. injection + access control");
println!(" /mcp on|off Playwright MCP browser /offline on|off self-test");
println!(" /votes <n> /agents <n>");
println!(" /run launch · /runs /results [n] /report [n] /status [n]");
println!(" /quit exit");
println!();
println!(" Example:");
println!(" /model anthropic:claude-opus-4-8");
println!(" /target http://testphp.vulnweb.com/");
println!(" find injection and broken access control");
println!(" /run");
}
fn onoff(b: bool) -> &'static str {
if b { "on" } else { "off" }
}
fn onoff(b: bool) -> &'static str { if b { "on" } else { "off" } }
fn trunc(s: &str, n: usize) -> String { if s.len() <= n { s.to_string() } else { format!("{}", &s[..n.saturating_sub(1)]) } }
+3 -3
View File
@@ -24,12 +24,12 @@ pub fn providers() -> Vec<Provider> {
vec![
Provider { key: "anthropic", label: "Anthropic Claude", base_url: "https://api.anthropic.com/v1", env_key: "ANTHROPIC_API_KEY", kind: "cli",
models: vec!["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"] },
Provider { key: "openai", label: "OpenAI", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli",
models: vec!["gpt-5.1", "o4"] },
Provider { key: "openai", label: "OpenAI (ChatGPT)", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli",
models: vec!["gpt-5.1", "gpt-5.1-codex", "o4"] },
Provider { key: "xai", label: "xAI Grok", base_url: "https://api.x.ai/v1", env_key: "XAI_API_KEY", kind: "cli",
models: vec!["grok-4", "grok-4-fast"] },
Provider { key: "gemini", label: "Google Gemini", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", env_key: "GEMINI_API_KEY", kind: "cli",
models: vec!["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"] },
models: vec!["gemini-3-pro", "gemini-2.5-pro", "gemini-2.5-flash"] },
Provider { key: "nvidia_nim", label: "NVIDIA NIM", base_url: "https://integrate.api.nvidia.com/v1", env_key: "NVIDIA_NIM_API_KEY", kind: "api",
models: vec!["nvidia/llama-3.3-nemotron-super-49b-v1", "deepseek-ai/deepseek-r1", "qwen/qwen2.5-coder-32b-instruct"] },
Provider { key: "deepseek", label: "DeepSeek", base_url: "https://api.deepseek.com/v1", env_key: "DEEPSEEK_API_KEY", kind: "api",
+50 -2
View File
@@ -195,7 +195,16 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
// ---- 4. Validate by N-model voting ---------------------------------
let findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
// ---- 5. Chain confirmed findings into deeper impact ----------------
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &tx).await;
if !chained.is_empty() {
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
let _ = tx.send(format!("chaining added {} validated finding(s)", extra.len())).await;
findings.extend(extra);
findings = dedup_findings(findings);
}
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
}
@@ -386,10 +395,49 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
let transcript = format!("{}\n{}", code_leads, transcript_of(&raw));
let candidates = dedup_findings(raw.iter().flat_map(|(_, _, f)| f.clone()).collect());
let _ = tx.send(format!("{} candidate finding(s) (deduped) — validating", candidates.len())).await;
let findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
let mut findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
let chained = chain_round(pool, &cfg.target, &recon, &operator_directives(&cfg), &findings, &tx).await;
if !chained.is_empty() {
let extra = validate(dedup_findings(chained), pool, VOTE_SYS, cfg.vote_n, &tx).await;
let _ = tx.send(format!("chaining added {} validated finding(s)", extra.len())).await;
findings.extend(extra);
findings = dedup_findings(findings);
}
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
}
const CHAIN_SYS: &str = "You are an exploit-chaining specialist. Given already-CONFIRMED findings, chain them into deeper impact — e.g. SSRF→cloud metadata creds, SQLi→DB dump→credential reuse, IDOR→account takeover, arbitrary file read→secrets→RCE, auth bypass→admin. Use your tools to actually carry the chain forward and PROVE the escalated impact. Report ONLY NEW findings beyond the inputs.";
/// One orchestration round: take the confirmed findings and try to chain them
/// into higher-impact follow-ups, reusing the recon/auth context. Returns the
/// (unvalidated) new candidate findings produced by chaining.
async fn chain_round(pool: &ModelPool, target: &str, recon: &str, directives: &str,
confirmed: &[Finding], tx: &Sender<String>) -> Vec<Finding> {
if confirmed.is_empty() {
return vec![];
}
let summary: String = confirmed.iter().take(20)
.map(|f| format!("- [{}] {} @ {} ({})", f.severity, f.title, f.endpoint, f.cwe))
.collect::<Vec<_>>().join("\n");
let _ = tx.send(format!("chaining {} confirmed finding(s) for deeper impact…", confirmed.len())).await;
let recon_ctx: String = recon.chars().take(2500).collect();
let user = format!(
"AUTHORIZED engagement on {target}.\n\n{directives}{react}{doctrine}\
CONFIRMED FINDINGS TO CHAIN:\n{summary}\n\nRecon:\n{recon_ctx}\n\n\
Chain these into deeper impact and PROVE it. Reply ONLY a JSON array of NEW findings \
(may be []): {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
react = REACT_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()),
);
match pool.complete_routed(Task::Exploit, CHAIN_SYS, &user).await {
Ok((m, text)) => {
let f = extract_findings(&text, "chain");
let _ = tx.send(format!("chain via {}{} new candidate(s)", m.label(), f.len())).await;
f
}
Err(e) => { let _ = tx.send(format!("chaining failed: {e}")).await; vec![] }
}
}
// --------------------------------------------------------------------------- shared
const SELECT_SYS: &str = "You are a penetration-test orchestrator. Given recon of a target and a catalog of specialist agents, choose ONLY the agents whose preconditions clearly match the target's attack surface. Be selective. Reply with a JSON array of agent names (strings) drawn exactly from the catalog. No prose.";