diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index ae12d3e..63519af 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -74,6 +74,34 @@ enum Cmd { #[arg(short, long)] 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, + /// 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, + #[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. Agents, /// List providers and models. @@ -161,12 +189,59 @@ async fn main() -> anyhow::Result<()> { let out = run_engagement(&base, cfg, false, true).await?; 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(()) } +/// 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 { + run_mode(base, cfg, mcp, Mode::Grey).await +} + /// 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 { +pub(crate) async fn run_engagement(base: &Path, cfg: RunConfig, mcp: bool, whitebox: bool) -> anyhow::Result { + 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 { let lib = agents::load(base); // Unique, sortable run id → runs// @@ -182,8 +257,11 @@ pub(crate) async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, w println!(" │ target : {}", cfg.target); println!(" │ models : {}", cfg.models.join(", ")); println!(" │ output : {}", workdir.display()); + if let Mode::Grey = mode { + println!(" │ repo : {}", cfg.repo.clone().unwrap_or_default()); + } 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 mcp { " · mcp" } else { "" }); @@ -224,10 +302,10 @@ pub(crate) async fn run_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, w println!(" [*] {line}"); } }); - let out = if whitebox { - harness::run_whitebox(cfg, &lib, &pool, tx).await - } else { - harness::run(cfg, &lib, &pool, tx).await + let out = match mode { + Mode::White => harness::run_whitebox(cfg, &lib, &pool, tx).await, + Mode::Grey => harness::run_greybox(cfg, &lib, &pool, tx).await, + Mode::Black => harness::run(cfg, &lib, &pool, tx).await, }; let _ = printer.await; diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index aa28ac0..d3204e7 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -20,6 +20,7 @@ struct Session { target: Option, repo: Option, auth: Option, + creds: Option, instructions: Option, } @@ -34,6 +35,7 @@ impl Default for Session { target: None, repo: None, auth: None, + creds: None, instructions: None, } } @@ -119,20 +121,23 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { 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) }; - s.repo = None; println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none)".into())); } "/repo" => { 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())); } "/auth" => { s.auth = if arg.is_empty() { None } else { Some(arg.to_string()) }; 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" => { s.instructions = if arg.is_empty() { None } else { Some(arg.to_string()) }; 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) { - let (target, whitebox) = match (&s.repo, &s.target) { - (Some(r), _) => (r.clone(), true), - (_, Some(t)) => (t.clone(), false), + // 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 or /repo first.\x1b[0m"); + println!(" \x1b[31m✗ set a /target and/or /repo first.\x1b[0m"); 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.subscription = s.subscription; cfg.vote_n = s.vote_n; @@ -169,8 +181,17 @@ async fn run(base: &Path, s: &Session) { cfg.verbose = true; cfg.instructions = s.instructions.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), Err(e) => println!(" \x1b[31m✗ run failed: {e}\x1b[0m"), } @@ -180,9 +201,17 @@ 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!(" │ 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!(" └─ /run to launch"); @@ -195,8 +224,9 @@ fn help() { println!(" /key set a provider API key (switches to API mode)"); println!(" /sub on|off use local subscription login instead of API key"); println!(" /target black-box target URL"); - println!(" /repo white-box: analyse a local repository"); + println!(" /repo analyse a local repo (repo+target = greybox: code + live)"); println!(" /auth auth to send (e.g. 'Authorization: Bearer ' or 'Cookie: s=..')"); + println!(" /creds load credentials (jwt/header/cookie/login) for authenticated tests"); println!(" /focus 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)"); diff --git a/neurosploit-rs/crates/harness/src/creds.rs b/neurosploit-rs/crates/harness/src/creds.rs new file mode 100644 index 0000000..03689a9 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/creds.rs @@ -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 +//! # 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, + pub header: Option, + pub cookie: Option, + pub login: Option, +} + +impl Creds { + pub fn load(path: &std::path::Path) -> Option { + 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 { + 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 { + 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() + } +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index d3c21f3..36f6e92 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -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}; diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 9995ef7..f9cb765 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -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) -> 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 = lib.code.iter().take(code_cap).cloned().collect(); + let leads: Vec = 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::>>().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 = 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 = 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 = if !chosen.is_empty() { + let sel: Vec = 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 = { 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::>().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)> = 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::>().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."; diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index 3b09080..43753a1 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -91,6 +91,9 @@ pub struct RunConfig { /// authenticated user (e.g. "Authorization: Bearer " or "Cookie: session=..."). #[serde(default)] pub auth: Option, + /// Greybox: a source repository to review alongside the live `target` URL. + #[serde(default)] + pub repo: Option, } fn default_vote() -> usize { @@ -115,6 +118,7 @@ impl RunConfig { verbose: false, instructions: None, auth: None, + repo: None, } } } diff --git a/neurosploit-rs/creds.example.yaml b/neurosploit-rs/creds.example.yaml new file mode 100644 index 0000000..14ffd91 --- /dev/null +++ b/neurosploit-rs/creds.example.yaml @@ -0,0 +1,22 @@ +# NeuroSploit — example credentials file for authenticated testing. +# Pass with: neurosploit greybox --url --creds creds.yaml +# or: neurosploit run --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