diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 63519af..d211d0c 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -54,6 +54,12 @@ enum Cmd { /// support MCP fall back to their built-in tools). #[arg(long)] mcp: bool, + /// Credentials YAML for authenticated testing (jwt/header/cookie/login). + #[arg(long)] + creds: Option, + /// Free-text focus, e.g. "injection and broken access control". + #[arg(long)] + focus: Option, /// Verbose: log each agent as it launches, recon, and votes. #[arg(short, long)] verbose: bool, @@ -162,7 +168,7 @@ async fn main() -> anyhow::Result<()> { } } } - Cmd::Run { url, models, max_agents, vote_n, offline, subscription, mcp, verbose } => { + Cmd::Run { url, models, max_agents, vote_n, offline, subscription, mcp, creds, focus, verbose } => { let url = if url.starts_with("http") { url } else { format!("https://{url}") }; let mut cfg = RunConfig::new(&url); cfg.max_agents = max_agents; @@ -170,9 +176,11 @@ async fn main() -> anyhow::Result<()> { 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()).await; let out = run_engagement(&base, cfg, mcp, false).await?; print_findings(&out); } @@ -202,7 +210,7 @@ async fn main() -> anyhow::Result<()> { if !models.is_empty() { cfg.models = models; } - apply_creds(&mut cfg, creds.as_deref()); + apply_creds(&mut cfg, creds.as_deref()).await; let out = run_greybox_engagement(&base, cfg, mcp).await?; print_findings(&out); } @@ -210,22 +218,38 @@ async fn main() -> anyhow::Result<()> { 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>) { +/// Load a creds.yaml into the run config. Direct material (jwt/header/cookie) is +/// used as-is; a `login:` flow is EXECUTED now (real HTTP) to capture a live +/// session cookie/token. If the auto-login fails, fall back to instructing the +/// agents to authenticate themselves. +pub(crate) async 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(); + let Some(c) = harness::creds::Creds::load(Path::new(p)) else { + eprintln!(" [!] no usable credentials in {p}"); + return; + }; + println!(" [*] loaded credentials from {p}"); + if cfg.auth.is_none() { + cfg.auth = c.auth_header(); + } + // No direct material but a login flow → perform it now. + if cfg.auth.is_none() { + if let Some(login) = &c.login { + println!(" [*] auto-login: {} {} ...", login.method, login.url); + match harness::creds::login(login).await { + Ok((auth, note)) => { + println!(" [*] authenticated — {note}"); + cfg.auth = Some(auth); + } + Err(e) => { + eprintln!(" [!] auto-login failed ({e}); agents will attempt to log in themselves"); + if let Some(instr) = c.login_instruction() { + let base = cfg.instructions.clone().unwrap_or_default(); + cfg.instructions = Some(format!("{instr}\n{base}")); + } + } } - 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}"), } } diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index d3204e7..6ba8ed3 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -184,7 +184,7 @@ async fn run(base: &Path, s: &Session) { if let M::Grey { repo, .. } = &m { cfg.repo = Some(repo.clone()); } - crate::apply_creds(&mut cfg, s.creds.as_deref()); + crate::apply_creds(&mut cfg, s.creds.as_deref()).await; let result = match m { M::Grey { .. } => crate::run_greybox_engagement(base, cfg, s.mcp).await, diff --git a/neurosploit-rs/crates/harness/src/creds.rs b/neurosploit-rs/crates/harness/src/creds.rs index 03689a9..cbe095f 100644 --- a/neurosploit-rs/crates/harness/src/creds.rs +++ b/neurosploit-rs/crates/harness/src/creds.rs @@ -116,6 +116,61 @@ impl Creds { } } +/// Perform the login flow now (real HTTP POST) and return an auth header to +/// reuse on every subsequent request: a `Cookie:` from Set-Cookie, or an +/// `Authorization: Bearer` from a token in the JSON response. Returns +/// (auth_header, note). Redirects are not followed so the login response's +/// Set-Cookie is visible. +pub async fn login(l: &Login) -> anyhow::Result<(String, String)> { + use reqwest::header::SET_COOKIE; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .build()?; + let form: Vec<(String, String)> = vec![ + (l.username_field.clone(), l.username.clone()), + (l.password_field.clone(), l.password.clone()), + ]; + let req = if l.method == "GET" { + client.get(&l.url).query(&form) + } else { + client.post(&l.url).form(&form) + }; + let resp = req.send().await?; + let status = resp.status(); + + // 1) session cookies from Set-Cookie on the login response + let mut cookie_pairs = Vec::new(); + for hv in resp.headers().get_all(SET_COOKIE) { + if let Ok(s) = hv.to_str() { + if let Some(pair) = s.split(';').next() { + let p = pair.trim(); + if !p.is_empty() { + cookie_pairs.push(p.to_string()); + } + } + } + } + let body = resp.text().await.unwrap_or_default(); + + // 2) bearer token from a JSON response body + if let Ok(v) = serde_json::from_str::(&body) { + for k in ["access_token", "token", "jwt", "id_token", "accessToken"] { + if let Some(t) = v.get(k).and_then(|x| x.as_str()).filter(|t| !t.is_empty()) { + return Ok((format!("Authorization: Bearer {t}"), format!("bearer token from JSON `{k}` (HTTP {status})"))); + } + } + } + if !cookie_pairs.is_empty() { + let cookie = cookie_pairs.join("; "); + // Soft success check (don't fail hard — many apps 302 on success). + let ok = l.success.is_empty() || body.contains(&l.success) || status.is_redirection() || status.is_success(); + let note = format!("session cookie captured (HTTP {status}{})", if ok { "" } else { ", success marker not seen" }); + return Ok((format!("Cookie: {cookie}"), note)); + } + anyhow::bail!("login returned no Set-Cookie or token (HTTP {status})") +} + fn unquote(s: &str) -> String { let s = s.trim(); if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)