v3.5.0: automated login — execute the login flow and capture the live session

- harness/creds::login(): performs the real HTTP login (POST/GET form), captures
  a session Cookie from Set-Cookie or a Bearer token from the JSON body, with a
  soft success check (no hard fail on 302). Redirects not followed so Set-Cookie
  is visible.
- apply_creds is now async: direct material (jwt/header/cookie) used as-is; a
  `login:` flow is EXECUTED to obtain a live session; on failure, falls back to
  instructing the agents to log in themselves.
- --creds + --focus added to `run` (authenticated black-box) too.
- Verified live against a local mock: POST /login → 302 + Set-Cookie captured as
  the auth header used on subsequent requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 20:14:58 -03:00
parent 7b1be0b424
commit ae3e49f133
3 changed files with 95 additions and 16 deletions
+39 -15
View File
@@ -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<String>,
/// Free-text focus, e.g. "injection and broken access control".
#[arg(long)]
focus: Option<String>,
/// 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}"),
}
}
+1 -1
View File
@@ -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,
@@ -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::<serde_json::Value>(&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)