mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-06-30 07:15:30 +02:00
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:
Generated
+230
-5
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)]) } }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user