mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-07-08 20:37:52 +02:00
v3.5.0: greybox (code + live) pipeline + credentials (creds.yaml / JWT / auth)
- New GREYBOX mode: review a repo's source AND exploit the running app in one pipeline — code-review findings become LEADS injected into live exploitation. CLI: `neurosploit greybox <repo> --url <app> [--creds creds.yaml] [--focus ...]` REPL: set both /repo and /target → greybox auto-selected. - Credentials (harness/src/creds.rs, dependency-free YAML subset): jwt / header / cookie, or an automated `login:` flow. Derives an auth header and/or a "authenticate first via curl" directive injected into prompts so agents test authenticated. --creds flag + /creds command + creds.example.yaml. - RunConfig gains `repo`; run_engagement refactored to a Mode enum (Black/White/Grey). - Verified offline: greybox loads creds, combines repo+URL, runs pipeline, writes report. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,34 @@ enum Cmd {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
},
|
},
|
||||||
|
/// Greybox: review a repo's source AND exploit the running app together.
|
||||||
|
Greybox {
|
||||||
|
/// Path to the source repository.
|
||||||
|
repo: String,
|
||||||
|
/// URL of the running application.
|
||||||
|
#[arg(long)]
|
||||||
|
url: String,
|
||||||
|
#[arg(long = "model")]
|
||||||
|
models: Vec<String>,
|
||||||
|
/// Credentials YAML for authenticated testing (jwt/header/cookie/login).
|
||||||
|
#[arg(long)]
|
||||||
|
creds: Option<String>,
|
||||||
|
/// Free-text focus, e.g. "injection and broken access control".
|
||||||
|
#[arg(long)]
|
||||||
|
focus: Option<String>,
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
max_agents: usize,
|
||||||
|
#[arg(long, default_value_t = 3)]
|
||||||
|
vote_n: usize,
|
||||||
|
#[arg(long)]
|
||||||
|
offline: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
subscription: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
mcp: bool,
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
},
|
||||||
/// Show agent library counts.
|
/// Show agent library counts.
|
||||||
Agents,
|
Agents,
|
||||||
/// List providers and models.
|
/// List providers and models.
|
||||||
@@ -161,12 +189,59 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let out = run_engagement(&base, cfg, false, true).await?;
|
let out = run_engagement(&base, cfg, false, true).await?;
|
||||||
print_findings(&out);
|
print_findings(&out);
|
||||||
}
|
}
|
||||||
|
Cmd::Greybox { repo, url, models, creds, focus, max_agents, vote_n, offline, subscription, mcp, verbose } => {
|
||||||
|
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||||
|
let mut cfg = RunConfig::new(&url);
|
||||||
|
cfg.repo = Some(repo);
|
||||||
|
cfg.max_agents = max_agents;
|
||||||
|
cfg.vote_n = vote_n;
|
||||||
|
cfg.offline = offline;
|
||||||
|
cfg.subscription = subscription;
|
||||||
|
cfg.verbose = verbose;
|
||||||
|
cfg.instructions = focus;
|
||||||
|
if !models.is_empty() {
|
||||||
|
cfg.models = models;
|
||||||
|
}
|
||||||
|
apply_creds(&mut cfg, creds.as_deref());
|
||||||
|
let out = run_greybox_engagement(&base, cfg, mcp).await?;
|
||||||
|
print_findings(&out);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a creds.yaml into the run config: derive the auth header and prepend any
|
||||||
|
/// login flow to the operator instructions.
|
||||||
|
pub(crate) fn apply_creds(cfg: &mut RunConfig, path: Option<&str>) {
|
||||||
|
let Some(p) = path else { return };
|
||||||
|
match harness::creds::Creds::load(Path::new(p)) {
|
||||||
|
Some(c) => {
|
||||||
|
if cfg.auth.is_none() {
|
||||||
|
cfg.auth = c.auth_header();
|
||||||
|
}
|
||||||
|
if let Some(login) = c.login_instruction() {
|
||||||
|
let base = cfg.instructions.clone().unwrap_or_default();
|
||||||
|
cfg.instructions = Some(format!("{login}\n{base}"));
|
||||||
|
}
|
||||||
|
println!(" [*] loaded credentials from {p}");
|
||||||
|
}
|
||||||
|
None => eprintln!(" [!] no usable credentials in {p}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(crate) enum Mode { Black, White, Grey }
|
||||||
|
|
||||||
|
pub(crate) async fn run_greybox_engagement(base: &Path, cfg: RunConfig, mcp: bool) -> anyhow::Result<RunOutput> {
|
||||||
|
run_mode(base, cfg, mcp, Mode::Grey).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared engagement runner for `run` / `whitebox` / the interactive session.
|
/// Shared engagement runner for `run` / `whitebox` / the interactive session.
|
||||||
pub(crate) async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, whitebox: bool) -> anyhow::Result<RunOutput> {
|
pub(crate) async fn run_engagement(base: &Path, cfg: RunConfig, mcp: bool, whitebox: bool) -> anyhow::Result<RunOutput> {
|
||||||
|
run_mode(base, cfg, mcp, if whitebox { Mode::White } else { Mode::Black }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_mode(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow::Result<RunOutput> {
|
||||||
let lib = agents::load(base);
|
let lib = agents::load(base);
|
||||||
|
|
||||||
// Unique, sortable run id → runs/<id>/
|
// Unique, sortable run id → runs/<id>/
|
||||||
@@ -182,8 +257,11 @@ pub(crate) async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, w
|
|||||||
println!(" │ target : {}", cfg.target);
|
println!(" │ target : {}", cfg.target);
|
||||||
println!(" │ models : {}", cfg.models.join(", "));
|
println!(" │ models : {}", cfg.models.join(", "));
|
||||||
println!(" │ output : {}", workdir.display());
|
println!(" │ output : {}", workdir.display());
|
||||||
|
if let Mode::Grey = mode {
|
||||||
|
println!(" │ repo : {}", cfg.repo.clone().unwrap_or_default());
|
||||||
|
}
|
||||||
println!(" └─ mode : {}{}{}",
|
println!(" └─ mode : {}{}{}",
|
||||||
if whitebox { "white-box" } else { "black-box" },
|
match mode { Mode::White => "white-box", Mode::Grey => "greybox", Mode::Black => "black-box" },
|
||||||
if cfg.subscription { " · subscription" } else { " · api" },
|
if cfg.subscription { " · subscription" } else { " · api" },
|
||||||
if mcp { " · mcp" } else { "" });
|
if mcp { " · mcp" } else { "" });
|
||||||
|
|
||||||
@@ -224,10 +302,10 @@ pub(crate) async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, w
|
|||||||
println!(" [*] {line}");
|
println!(" [*] {line}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let out = if whitebox {
|
let out = match mode {
|
||||||
harness::run_whitebox(cfg, &lib, &pool, tx).await
|
Mode::White => harness::run_whitebox(cfg, &lib, &pool, tx).await,
|
||||||
} else {
|
Mode::Grey => harness::run_greybox(cfg, &lib, &pool, tx).await,
|
||||||
harness::run(cfg, &lib, &pool, tx).await
|
Mode::Black => harness::run(cfg, &lib, &pool, tx).await,
|
||||||
};
|
};
|
||||||
let _ = printer.await;
|
let _ = printer.await;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct Session {
|
|||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
repo: Option<String>,
|
repo: Option<String>,
|
||||||
auth: Option<String>,
|
auth: Option<String>,
|
||||||
|
creds: Option<String>,
|
||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ impl Default for Session {
|
|||||||
target: None,
|
target: None,
|
||||||
repo: None,
|
repo: None,
|
||||||
auth: None,
|
auth: None,
|
||||||
|
creds: None,
|
||||||
instructions: None,
|
instructions: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,20 +121,23 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
|||||||
println!(" subscription: {}", onoff(s.subscription));
|
println!(" subscription: {}", onoff(s.subscription));
|
||||||
}
|
}
|
||||||
"/target" | "/url" => {
|
"/target" | "/url" => {
|
||||||
|
// target + repo can coexist → greybox.
|
||||||
let t = if arg.starts_with("http") || arg.is_empty() { arg.to_string() } else { format!("https://{arg}") };
|
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) };
|
s.target = if t.is_empty() { None } else { Some(t) };
|
||||||
s.repo = None;
|
|
||||||
println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
}
|
}
|
||||||
"/repo" => {
|
"/repo" => {
|
||||||
s.repo = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
s.repo = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
s.target = None;
|
|
||||||
println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none)".into()));
|
println!(" repo: {}", s.repo.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
}
|
}
|
||||||
"/auth" => {
|
"/auth" => {
|
||||||
s.auth = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
s.auth = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
println!(" auth: {}", s.auth.clone().unwrap_or_else(|| "(none)".into()));
|
println!(" auth: {}", s.auth.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
}
|
}
|
||||||
|
"/creds" => {
|
||||||
|
s.creds = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
|
println!(" creds file: {}", s.creds.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
|
}
|
||||||
"/focus" | "/instructions" => {
|
"/focus" | "/instructions" => {
|
||||||
s.instructions = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
s.instructions = if arg.is_empty() { None } else { Some(arg.to_string()) };
|
||||||
println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into()));
|
println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
@@ -153,15 +158,22 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(base: &Path, s: &Session) {
|
async fn run(base: &Path, s: &Session) {
|
||||||
let (target, whitebox) = match (&s.repo, &s.target) {
|
// repo + target → greybox; repo only → whitebox; target only → black-box.
|
||||||
(Some(r), _) => (r.clone(), true),
|
enum M { Black(String), White(String), Grey { url: String, repo: String } }
|
||||||
(_, Some(t)) => (t.clone(), false),
|
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> or /repo <path> first.\x1b[0m");
|
println!(" \x1b[31m✗ set a /target <url> and/or /repo <path> first.\x1b[0m");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut cfg = RunConfig::new(&target);
|
let primary = match &m {
|
||||||
|
M::Black(t) | M::White(t) => t.clone(),
|
||||||
|
M::Grey { url, .. } => url.clone(),
|
||||||
|
};
|
||||||
|
let mut cfg = RunConfig::new(&primary);
|
||||||
cfg.models = s.models.clone();
|
cfg.models = s.models.clone();
|
||||||
cfg.subscription = s.subscription;
|
cfg.subscription = s.subscription;
|
||||||
cfg.vote_n = s.vote_n;
|
cfg.vote_n = s.vote_n;
|
||||||
@@ -169,8 +181,17 @@ async fn run(base: &Path, s: &Session) {
|
|||||||
cfg.verbose = true;
|
cfg.verbose = true;
|
||||||
cfg.instructions = s.instructions.clone();
|
cfg.instructions = s.instructions.clone();
|
||||||
cfg.auth = s.auth.clone();
|
cfg.auth = s.auth.clone();
|
||||||
|
if let M::Grey { repo, .. } = &m {
|
||||||
|
cfg.repo = Some(repo.clone());
|
||||||
|
}
|
||||||
|
crate::apply_creds(&mut cfg, s.creds.as_deref());
|
||||||
|
|
||||||
match crate::run_engagement(base, cfg, s.mcp && !whitebox, whitebox).await {
|
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),
|
||||||
Err(e) => println!(" \x1b[31m✗ run failed: {e}\x1b[0m"),
|
Err(e) => println!(" \x1b[31m✗ run failed: {e}\x1b[0m"),
|
||||||
}
|
}
|
||||||
@@ -180,9 +201,17 @@ fn show(s: &Session) {
|
|||||||
println!(" ┌─ session");
|
println!(" ┌─ session");
|
||||||
println!(" │ models : {}", s.models.join(", "));
|
println!(" │ models : {}", s.models.join(", "));
|
||||||
println!(" │ auth mode: {}", if s.subscription { "subscription (CLI login)" } else { "API key" });
|
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!(" │ mode : {mode}");
|
||||||
println!(" │ target : {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
println!(" │ target : {}", s.target.clone().unwrap_or_else(|| "(none)".into()));
|
||||||
println!(" │ repo : {}", s.repo.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!(" │ 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!(" │ 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!(" │ mcp : {} votes: {} max-agents: {}", onoff(s.mcp), s.vote_n, s.max_agents);
|
||||||
println!(" └─ /run to launch");
|
println!(" └─ /run to launch");
|
||||||
@@ -195,8 +224,9 @@ fn help() {
|
|||||||
println!(" /key <prov> <key> set a provider API key (switches to API mode)");
|
println!(" /key <prov> <key> set a provider API key (switches to API mode)");
|
||||||
println!(" /sub on|off use local subscription login instead of API key");
|
println!(" /sub on|off use local subscription login instead of API key");
|
||||||
println!(" /target <url> black-box target URL");
|
println!(" /target <url> black-box target URL");
|
||||||
println!(" /repo <path> white-box: analyse a local repository");
|
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!(" /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!(" /focus <text> steer the tests, e.g. 'injection and broken access control'");
|
||||||
println!(" (or just type the instruction with no slash)");
|
println!(" (or just type the instruction with no slash)");
|
||||||
println!(" /mcp on|off enable Playwright MCP browser (subscription path)");
|
println!(" /mcp on|off enable Playwright MCP browser (subscription path)");
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! Credential loading for authenticated testing (`creds.yaml`).
|
||||||
|
//!
|
||||||
|
//! Dependency-free parser for a small YAML subset: flat `key: value` pairs plus
|
||||||
|
//! one nested `login:` block (2-space indent). Lets the operator hand the
|
||||||
|
//! harness a JWT / header / cookie, or a login flow the agents should perform so
|
||||||
|
//! they test the target as an authenticated user.
|
||||||
|
//!
|
||||||
|
//! Example `creds.yaml`:
|
||||||
|
//! ```yaml
|
||||||
|
//! jwt: eyJhbGciOi... # → Authorization: Bearer <jwt>
|
||||||
|
//! # header: "X-Api-Key: abc123" # raw header (alternative)
|
||||||
|
//! # cookie: "session=deadbeef" # → Cookie: session=deadbeef
|
||||||
|
//! login:
|
||||||
|
//! url: http://app/login
|
||||||
|
//! method: POST
|
||||||
|
//! username_field: uid
|
||||||
|
//! password_field: passw
|
||||||
|
//! username: admin
|
||||||
|
//! password: admin
|
||||||
|
//! success: Logout
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone)]
|
||||||
|
pub struct Login {
|
||||||
|
pub url: String,
|
||||||
|
pub method: String,
|
||||||
|
pub username_field: String,
|
||||||
|
pub password_field: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub success: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone)]
|
||||||
|
pub struct Creds {
|
||||||
|
pub jwt: Option<String>,
|
||||||
|
pub header: Option<String>,
|
||||||
|
pub cookie: Option<String>,
|
||||||
|
pub login: Option<Login>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Creds {
|
||||||
|
pub fn load(path: &std::path::Path) -> Option<Creds> {
|
||||||
|
let text = std::fs::read_to_string(path).ok()?;
|
||||||
|
let mut c = Creds::default();
|
||||||
|
let mut login = Login { method: "POST".into(), ..Default::default() };
|
||||||
|
let mut in_login = false;
|
||||||
|
let mut have_login = false;
|
||||||
|
for raw in text.lines() {
|
||||||
|
let line = raw.split('#').next().unwrap_or("");
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let indented = line.starts_with(' ') || line.starts_with('\t');
|
||||||
|
let (k, v) = match line.split_once(':') {
|
||||||
|
Some((k, v)) => (k.trim().to_string(), unquote(v.trim())),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if k == "login" && v.is_empty() {
|
||||||
|
in_login = true;
|
||||||
|
have_login = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_login && indented {
|
||||||
|
match k.as_str() {
|
||||||
|
"url" => login.url = v,
|
||||||
|
"method" => login.method = v.to_uppercase(),
|
||||||
|
"username_field" => login.username_field = v,
|
||||||
|
"password_field" => login.password_field = v,
|
||||||
|
"username" | "user" => login.username = v,
|
||||||
|
"password" | "pass" => login.password = v,
|
||||||
|
"success" => login.success = v,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
in_login = false;
|
||||||
|
match k.as_str() {
|
||||||
|
"jwt" | "token" => c.jwt = Some(v),
|
||||||
|
"header" => c.header = Some(v),
|
||||||
|
"cookie" => c.cookie = Some(v),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if have_login && !login.url.is_empty() {
|
||||||
|
c.login = Some(login);
|
||||||
|
}
|
||||||
|
if c.jwt.is_none() && c.header.is_none() && c.cookie.is_none() && c.login.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The auth material to send with each request, as a header line.
|
||||||
|
pub fn auth_header(&self) -> Option<String> {
|
||||||
|
if let Some(h) = &self.header {
|
||||||
|
return Some(h.clone());
|
||||||
|
}
|
||||||
|
if let Some(j) = &self.jwt {
|
||||||
|
return Some(format!("Authorization: Bearer {j}"));
|
||||||
|
}
|
||||||
|
if let Some(ck) = &self.cookie {
|
||||||
|
return Some(format!("Cookie: {ck}"));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directive instructing the agent to authenticate first via curl.
|
||||||
|
pub fn login_instruction(&self) -> Option<String> {
|
||||||
|
let l = self.login.as_ref()?;
|
||||||
|
Some(format!(
|
||||||
|
"AUTHENTICATE FIRST: {} {} with {}={} and {}={}; capture the session cookie/token \
|
||||||
|
from the response (success indicator: \"{}\") and reuse it on every subsequent request.",
|
||||||
|
l.method, l.url, l.username_field, l.username, l.password_field, l.password, l.success
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unquote(s: &str) -> String {
|
||||||
|
let s = s.trim();
|
||||||
|
if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|
||||||
|
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
|
||||||
|
{
|
||||||
|
s[1..s.len() - 1].to_string()
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! **N-model voting** before scoring and reporting.
|
//! **N-model voting** before scoring and reporting.
|
||||||
|
|
||||||
pub mod agents;
|
pub mod agents;
|
||||||
|
pub mod creds;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
@@ -19,7 +20,7 @@ pub use models::{
|
|||||||
cli_binary_for, ensure_playwright_mcp, installed_cli_backends, mcp_supported, provider_for,
|
cli_binary_for, ensure_playwright_mcp, installed_cli_backends, mcp_supported, provider_for,
|
||||||
providers, write_mcp_config, ChatClient, ModelRef, Provider,
|
providers, write_mcp_config, ChatClient, ModelRef, Provider,
|
||||||
};
|
};
|
||||||
pub use pipeline::{run_whitebox, RunOutput};
|
pub use pipeline::{run_greybox, run_whitebox, RunOutput};
|
||||||
pub use pipeline::run;
|
pub use pipeline::run;
|
||||||
pub use pool::{ModelPool, Task};
|
pub use pool::{ModelPool, Task};
|
||||||
pub use types::{Finding, RunConfig};
|
pub use types::{Finding, RunConfig};
|
||||||
|
|||||||
@@ -257,6 +257,137 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
|
|||||||
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
|
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Greybox engagement: review the source code AND exploit the running app in one
|
||||||
|
/// pipeline — code-review findings become *leads* that guide live exploitation
|
||||||
|
/// (with credentials/auth so testing is authenticated).
|
||||||
|
pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
|
||||||
|
let repo = cfg.repo.clone().unwrap_or_default();
|
||||||
|
let _ = tx.send(format!("GREYBOX · live: {} · repo: {} · {} code agents",
|
||||||
|
cfg.target, repo, lib.code.len())).await;
|
||||||
|
|
||||||
|
// ---- 1. Recon the live target -------------------------------------
|
||||||
|
let recon = if cfg.offline {
|
||||||
|
"{}".to_string()
|
||||||
|
} else {
|
||||||
|
match pool.complete_routed(Task::Recon, RECON_SYS,
|
||||||
|
&format!("{}{}Target: {}", operator_directives(&cfg), tool_doctrine(pool.mcp_config.is_some()), cfg.target)).await {
|
||||||
|
Ok((m, t)) => { let _ = tx.send(format!("recon complete via {}", m.label())).await; t }
|
||||||
|
Err(e) => { let _ = tx.send(format!("recon failed ({e})")).await; "{}".to_string() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 2. Review the source for leads -------------------------------
|
||||||
|
let context = collect_repo_context(Path::new(&repo), 200, 90_000);
|
||||||
|
let _ = tx.send(format!("collected {} bytes of source for code review", context.len())).await;
|
||||||
|
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut code_leads = String::new();
|
||||||
|
if !cfg.offline && !context.is_empty() {
|
||||||
|
let code_cap = if cfg.max_agents > 0 { cfg.max_agents.min(lib.code.len()) } else { lib.code.len().min(12) };
|
||||||
|
let code_agents: Vec<Agent> = lib.code.iter().take(code_cap).cloned().collect();
|
||||||
|
let leads: Vec<Finding> = stream::iter(code_agents.into_iter())
|
||||||
|
.map(|ag| {
|
||||||
|
let ctx = context.clone();
|
||||||
|
let txc = tx.clone();
|
||||||
|
async move {
|
||||||
|
let user = format!(
|
||||||
|
"{}\n\nSOURCE:\n```\n{}\n```\nReply ONLY a JSON array of issues (may be []): \
|
||||||
|
{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}} \
|
||||||
|
where endpoint is file:line.",
|
||||||
|
ag.user.replace("{target}", "the repository").replace("{recon_json}", "{}"), ctx
|
||||||
|
);
|
||||||
|
match pool.complete_routed(Task::Select, &ag.system, &user).await {
|
||||||
|
Ok((_, text)) => { let f = extract_findings(&text, &ag.name);
|
||||||
|
let _ = txc.send(format!("review {} → {} lead(s)", ag.name, f.len())).await; f }
|
||||||
|
Err(_) => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buffer_unordered(cfg.concurrency)
|
||||||
|
.collect::<Vec<Vec<Finding>>>().await.into_iter().flatten().collect();
|
||||||
|
let leads = dedup_findings(leads);
|
||||||
|
if !leads.is_empty() {
|
||||||
|
code_leads.push_str("CODE-REVIEW LEADS (confirm these against the LIVE app):\n");
|
||||||
|
for l in leads.iter().take(25) {
|
||||||
|
code_leads.push_str(&format!("- [{}] {} @ {} ({})\n", l.severity, l.title, l.endpoint, l.cwe));
|
||||||
|
}
|
||||||
|
code_leads.push('\n');
|
||||||
|
}
|
||||||
|
let _ = tx.send(format!("{} code lead(s) → guiding live exploitation", leads.len())).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. Select live agents (recon + focus + code leads) -----------
|
||||||
|
let mut ranked: Vec<Agent> = lib.vulns.clone();
|
||||||
|
ranked.sort_by(|a, b| rl.weight(&b.name).partial_cmp(&rl.weight(&a.name)).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(ranked.len()) } else { ranked.len() };
|
||||||
|
let focus = format!("{} {}", cfg.instructions.clone().unwrap_or_default(), code_leads);
|
||||||
|
|
||||||
|
if cfg.offline {
|
||||||
|
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![],
|
||||||
|
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen = select_agents(pool, &recon, &focus, &ranked, &tx).await;
|
||||||
|
let selected: Vec<Agent> = if !chosen.is_empty() {
|
||||||
|
let sel: Vec<Agent> = ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect();
|
||||||
|
if sel.is_empty() { heuristic_select(&ranked, &recon, &focus, cap) } else { sel.into_iter().take(cap).collect() }
|
||||||
|
} else {
|
||||||
|
heuristic_select(&ranked, &recon, &focus, cap)
|
||||||
|
};
|
||||||
|
let selected: Vec<Agent> = { let mut seen = std::collections::HashSet::new();
|
||||||
|
selected.into_iter().filter(|a| seen.insert(a.name.clone())).collect() };
|
||||||
|
let _ = tx.send(format!("selected {} live agent(s): {}", selected.len(),
|
||||||
|
selected.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", "))).await;
|
||||||
|
|
||||||
|
// ---- 4. Exploit live, guided by code leads ------------------------
|
||||||
|
let target = cfg.target.clone();
|
||||||
|
let verbose = cfg.verbose;
|
||||||
|
let mcp_on = pool.mcp_config.is_some();
|
||||||
|
let directives = operator_directives(&cfg);
|
||||||
|
let recon_ctx: String = recon.chars().take(3000).collect();
|
||||||
|
let leads_ctx = code_leads.clone();
|
||||||
|
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||||
|
.map(|ag| {
|
||||||
|
let target = target.clone();
|
||||||
|
let recon = recon_ctx.clone();
|
||||||
|
let directives = directives.clone();
|
||||||
|
let leads = leads_ctx.clone();
|
||||||
|
let txc = tx.clone();
|
||||||
|
async move {
|
||||||
|
if verbose {
|
||||||
|
let _ = txc.send(format!(" ▶ launching agent: {} ({})", ag.name, ag.title.replace(" Agent", ""))).await;
|
||||||
|
}
|
||||||
|
let user = format!(
|
||||||
|
"AUTHORIZED greybox engagement on {target} — you also have the source review below. \
|
||||||
|
Proceed and PROVE each issue against the LIVE app.\n\n{directives}{leads}{react}{doctrine}{body}\n\n\
|
||||||
|
Reply ONLY a JSON array of confirmed findings (may be []): \
|
||||||
|
{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
|
||||||
|
target = target, directives = directives, leads = leads,
|
||||||
|
react = REACT_DOCTRINE, doctrine = tool_doctrine(mcp_on),
|
||||||
|
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||||
|
);
|
||||||
|
match pool.complete_routed(Task::Exploit, &ag.system, &user).await {
|
||||||
|
Ok((m, text)) => { let f = extract_findings(&text, &ag.name);
|
||||||
|
let _ = txc.send(format!("exploit {} via {} → {} candidate(s)", ag.name, m.label(), f.len())).await;
|
||||||
|
(ag.name.clone(), text, f) }
|
||||||
|
Err(e) => { let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
|
||||||
|
(ag.name.clone(), format!("ERROR: {e}"), vec![]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.buffer_unordered(cfg.concurrency)
|
||||||
|
.collect::<Vec<_>>().await;
|
||||||
|
|
||||||
|
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;
|
||||||
|
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- shared
|
// --------------------------------------------------------------------------- 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.";
|
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.";
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ pub struct RunConfig {
|
|||||||
/// authenticated user (e.g. "Authorization: Bearer <jwt>" or "Cookie: session=...").
|
/// authenticated user (e.g. "Authorization: Bearer <jwt>" or "Cookie: session=...").
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub auth: Option<String>,
|
pub auth: Option<String>,
|
||||||
|
/// Greybox: a source repository to review alongside the live `target` URL.
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_vote() -> usize {
|
fn default_vote() -> usize {
|
||||||
@@ -115,6 +118,7 @@ impl RunConfig {
|
|||||||
verbose: false,
|
verbose: false,
|
||||||
instructions: None,
|
instructions: None,
|
||||||
auth: None,
|
auth: None,
|
||||||
|
repo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# NeuroSploit — example credentials file for authenticated testing.
|
||||||
|
# Pass with: neurosploit greybox <repo> --url <app> --creds creds.yaml
|
||||||
|
# or: neurosploit run <url> --creds creds.yaml (after adding --creds support)
|
||||||
|
# or in the interactive session: /creds creds.yaml
|
||||||
|
#
|
||||||
|
# Provide ANY of the auth materials below (first match wins), and/or a `login`
|
||||||
|
# flow the agents will perform with curl before testing.
|
||||||
|
|
||||||
|
# --- direct auth material (pick one) ---
|
||||||
|
jwt: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4ifQ.signature
|
||||||
|
# header: "X-Api-Key: 0123456789abcdef"
|
||||||
|
# cookie: "session=deadbeef; role=admin"
|
||||||
|
|
||||||
|
# --- OR an automated login flow ---
|
||||||
|
login:
|
||||||
|
url: http://localhost:8080/login
|
||||||
|
method: POST
|
||||||
|
username_field: username
|
||||||
|
password_field: password
|
||||||
|
username: admin
|
||||||
|
password: password
|
||||||
|
success: Logout # text that appears on a successful login
|
||||||
Reference in New Issue
Block a user