mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-15 06:00:28 +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:
co-authored by
Claude Opus 4.8
parent
435463979b
commit
7b1be0b424
@@ -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.
|
||||
|
||||
pub mod agents;
|
||||
pub mod creds;
|
||||
pub mod models;
|
||||
pub mod pipeline;
|
||||
pub mod pool;
|
||||
@@ -19,7 +20,7 @@ pub use models::{
|
||||
cli_binary_for, ensure_playwright_mcp, installed_cli_backends, mcp_supported, provider_for,
|
||||
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 pool::{ModelPool, Task};
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
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=...").
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -115,6 +118,7 @@ impl RunConfig {
|
||||
verbose: false,
|
||||
instructions: None,
|
||||
auth: None,
|
||||
repo: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user