diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index a115e8c..738564b 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -91,6 +91,11 @@ struct Cli { /// Falls back to NEO4J_URI; credentials only via NEO4J_USER / NEO4J_PASSWORD. #[arg(long = "neo4j", global = true)] neo4j: Option, + /// Import an API collection (Postman / OpenAPI / Swagger / HAR, JSON) as + /// known in-scope surface: its endpoints are injected into every agent and + /// tested directly, alongside what recon discovers. + #[arg(long = "collection", global = true)] + collection: Option, /// Decision backend for the calibrated System One layer: /// typesafe (hosted API, needs TYPESAFE_API_KEY) or laya (local, free, /// open-source — downloads the model on first use and keeps evidence on the @@ -924,6 +929,7 @@ async fn main() -> anyhow::Result<()> { cfg.authz_matrix = cli.authz_matrix.clone(); cfg.creds_file = creds.clone(); cfg.neo4j = cli.neo4j.clone(); + apply_collection(&mut cfg, cli.collection.as_deref())?; if !models.is_empty() { cfg.models = models; } @@ -970,6 +976,7 @@ async fn main() -> anyhow::Result<()> { cfg.instructions = focus; cfg.pinned = parse_only(&only); cfg.research = research; + apply_collection(&mut cfg, cli.collection.as_deref())?; if quick { apply_quick(&mut cfg); } if !models.is_empty() { cfg.models = models; @@ -1047,6 +1054,7 @@ async fn main() -> anyhow::Result<()> { cfg.verbose = verbose; cfg.instructions = focus; cfg.auth = auth; + apply_collection(&mut cfg, cli.collection.as_deref())?; if !models.is_empty() { cfg.models = models; } let out = run_mode(&base, cfg, false, Mode::Ai).await?; print_findings(&out); @@ -2239,6 +2247,18 @@ fn apply_quick(cfg: &mut RunConfig) { println!(" \x1b[2mquick: economy preset — 1 voter, 1 chain round, light recon, ≤6 agents, eco budget\x1b[0m"); } +/// Validate and attach an imported API collection (Postman/OpenAPI/HAR) so a +/// bad file or format fails on the command line, not mid-run, and the operator +/// sees how many endpoints were seeded. +fn apply_collection(cfg: &mut RunConfig, path: Option<&str>) -> anyhow::Result<()> { + let Some(path) = path.filter(|p| !p.trim().is_empty()) else { return Ok(()) }; + let eps = harness::collection::parse_file(path) + .map_err(|e| anyhow::anyhow!("--collection {path}: {e}"))?; + println!(" \x1b[2mcollection: imported {} endpoint(s) from {} — tested as known surface\x1b[0m", eps.len(), path); + cfg.api_collection = Some(path.to_string()); + Ok(()) +} + /// Egress route, out-of-band channel and inbound SMS. /// /// The transport spec is parsed here rather than at run time so a typo fails diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index d143023..64f111f 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -147,7 +147,7 @@ struct LiveCheckpoint { /// near-neighbour would break working commands. A test keeps the two in sync. pub(crate) const ACCEPTED: &[&str] = &[ "/?", "/agents", "/attach", "/audit", "/auth", "/burp", "/cap", "/capability", "/chain", "/changed", "/clear", "/config", - "/context", "/continue", "/creds", "/diff", "/exclude", "/exit", "/expand", "/feed", + "/collection", "/import-api", "/context", "/continue", "/creds", "/diff", "/exclude", "/exit", "/expand", "/feed", "/pocs", "/poc", "/evidence", "/artifacts", "/finding", "/findings", "/focus", "/forget", "/full", "/go", "/goal", "/graph", "/guardrail", "/guardrails", "/help", "/history", "/idle", "/inscope", "/instructions", "/integration", "/integrations", "/key", "/log", "/authorize", "/grant", "/inscope-set", "/scope-file", "/scopefile", "/import-scope", "/authorization", "/authz", "/program", "/logs", "/mcp", "/memory", "/model", "/models", "/objective", "/objectives", "/observe", @@ -162,7 +162,7 @@ pub(crate) const ACCEPTED: &[&str] = &[ /// All slash-commands, for Tab completion. const COMMANDS: &[&str] = &[ "/help", "/onboard", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target", - "/scope-file", "/authorization", "/class", "/repo", "/auth", "/creds", "/focus", "/objective", "/scope-out", "/attach", "/context", "/mcp", "/offline", + "/scope-file", "/authorization", "/class", "/repo", "/auth", "/creds", "/collection", "/focus", "/objective", "/scope-out", "/attach", "/context", "/mcp", "/offline", "/class", "/research", "/quick", "/economy", "/eco", "/sandbox", "/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/only", "/theme", "/clear", "/run", "/stop", "/pause", "/continue", "/runs", "/results", "/report", "/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/pocs", "/expand", "/integrations", "/memory", "/forget", "/graph", "/inscope", "/observe", "/guardrail", "/policy", @@ -285,6 +285,8 @@ struct Session { /// Named identities for multi-role access-control testing (name, header line). roles: Vec<(String, String)>, creds: Option, + /// Imported API collection (Postman/OpenAPI/HAR) path — tested as surface. + collection: Option, instructions: Option, /// Engagement objective / rules-of-engagement context (why + what matters). objective: Option, @@ -349,6 +351,7 @@ impl Default for Session { auth: None, roles: Vec::new(), creds: None, + collection: None, instructions: None, objective: None, authorization: None, @@ -854,6 +857,18 @@ pub async fn repl(base: &Path, auth: SessionAuth) -> anyhow::Result<()> { else if arg == "clear" { s.creds = None; println!(" creds cleared"); } else { s.creds = Some(arg.to_string()); println!(" creds file: {arg}"); } } + "/collection" | "/import-api" => { + if arg.is_empty() { + println!(" api collection: {}", s.collection.clone().unwrap_or_else(|| "(none) — import with /collection (Postman/OpenAPI/HAR, JSON)".into())); + } else if arg == "clear" { + s.collection = None; println!(" api collection cleared"); + } else { + match harness::collection::parse_file(arg) { + Ok(eps) => { s.collection = Some(arg.to_string()); println!(" api collection: imported {} endpoint(s) from {arg} — tested as known surface", eps.len()); } + Err(e) => println!(" \x1b[33m⚠ {e}\x1b[0m"), + } + } + } "/focus" | "/instructions" => { if arg == "clear" { s.instructions = None; println!(" focus cleared"); continue; } if arg.is_empty() { println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into())); continue; } @@ -1858,6 +1873,7 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { if let M::Grey { repo, .. } = &m { cfg.repo = Some(repo.clone()); } + cfg.api_collection = s.collection.clone(); crate::apply_creds(&mut cfg, s.creds.as_deref()).await; let mode = match &m { M::Grey { .. } => "greybox", M::White(_) => "white-box", M::Black(_) => "black-box" }; @@ -1938,6 +1954,7 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader, cfg.pinned = s.pinned.clone(); apply_session_budget(&mut cfg, s); if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); } + cfg.api_collection = s.collection.clone(); crate::apply_creds(&mut cfg, s.creds.as_deref()).await; crate::subscription_preflight(&cfg).await; // warn early if the CLI isn't logged in @@ -2324,6 +2341,8 @@ struct Snapshot { repo: Option, auth: Option, creds: Option, + #[serde(default)] + collection: Option, instructions: Option, #[serde(default)] objective: Option, @@ -2346,6 +2365,7 @@ fn save_session(s: &Session) { models: s.models.clone(), subscription: s.subscription, mcp: s.mcp, vote_n: s.vote_n, max_agents: s.max_agents, target: s.target.clone(), repo: s.repo.clone(), auth: s.auth.clone(), creds: s.creds.clone(), + collection: s.collection.clone(), instructions: s.instructions.clone(), objective: s.objective.clone(), out_of_scope: s.out_of_scope.clone(), scope_in: s.policy.hard.iter().map(|p| p.as_text()).collect(), @@ -2363,7 +2383,7 @@ fn load_session(s: &mut Session) -> bool { if snap.vote_n > 0 { s.vote_n = snap.vote_n; } s.max_agents = snap.max_agents; s.target = snap.target; s.repo = snap.repo; s.auth = snap.auth; - s.creds = snap.creds; s.instructions = snap.instructions; + s.creds = snap.creds; s.collection = snap.collection; s.instructions = snap.instructions; s.objective = snap.objective; s.out_of_scope = snap.out_of_scope; if let Some(soft) = snap.soft { s.policy.soft = soft; } for t in snap.scope_in { s.policy.allow(&t); } @@ -2639,6 +2659,7 @@ fn show(s: &Session) { 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!(" │ collection: {}", s.collection.clone().unwrap_or_else(|| "(none — /collection to import Postman/OpenAPI/HAR)".into())); println!(" │ proxy : {}", s.proxy.clone().unwrap_or_else(|| "(none — /proxy for Burp/ZAP)".into())); println!(" │ user-agent: {}", s.user_agent.clone().unwrap_or_else(|| "NeuroSploit (default)".into())); println!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into())); @@ -2703,6 +2724,7 @@ fn help() { h("/repo ", "source repo (greybox) OR skill/n8n file/folder to audit (skills scope)"); h("/auth ", "auth header (Bearer/cookie/key). Roles: /auth admin · /auth user "); h("/creds ", "creds: jwt/header/cookie/login + ssh/windows + aws/gcp/azure + roles"); + h("/collection ", "import an API collection (Postman/OpenAPI/HAR, JSON) as known surface to test"); h("/focus ", "steer the tests (or just type the instruction)"); h("/objective ", "engagement goal/context — shapes what agents prioritise & count as impact"); h("/scope-out ", "out-of-scope exclusions — host-shaped entries become ENFORCED denials"); diff --git a/neurosploit-rs/crates/harness/src/collection.rs b/neurosploit-rs/crates/harness/src/collection.rs new file mode 100644 index 0000000..7827a7d --- /dev/null +++ b/neurosploit-rs/crates/harness/src/collection.rs @@ -0,0 +1,303 @@ +//! Import an API collection as a test surface. +//! +//! An operator often already has the API mapped — a Postman collection, an +//! OpenAPI/Swagger spec, or a HAR capture from the browser. Rather than make +//! recon re-discover those endpoints (and maybe miss the undocumented or +//! auth-gated ones), we parse the collection into a flat endpoint list and +//! inject it into the engagement as known surface, so every agent tests exactly +//! those routes. +//! +//! Supported inputs (auto-detected by shape), JSON only: +//! - Postman Collection v2.x (`info` + `item[]`, nested folders allowed) +//! - OpenAPI 3.x / Swagger 2.0 (`openapi`/`swagger` + `paths{}`) +//! - HAR 1.2 (`log.entries[].request`) +//! +//! YAML OpenAPI is not parsed here (no YAML dependency) — convert to JSON first. + +use anyhow::{anyhow, Result}; +use serde_json::Value; + +/// One endpoint recovered from a collection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SeedEndpoint { + pub method: String, + pub url: String, + /// Short human hint (summary / body content-type / folder), may be empty. + pub note: String, +} + +/// Which collection format a JSON document is, by shape. +fn detect(v: &Value) -> &'static str { + if v.get("log").and_then(|l| l.get("entries")).is_some() { + "har" + } else if v.get("openapi").is_some() || v.get("swagger").is_some() { + "openapi" + } else if v.get("item").is_some() || v.get("info").is_some() { + "postman" + } else { + "unknown" + } +} + +/// Parse a collection file into a de-duplicated endpoint list. +pub fn parse_file(path: &str) -> Result> { + let raw = std::fs::read_to_string(path) + .map_err(|e| anyhow!("cannot read collection '{path}': {e}"))?; + parse_str(&raw) +} + +/// Parse a collection from a JSON string. +pub fn parse_str(raw: &str) -> Result> { + let v: Value = serde_json::from_str(raw.trim()) + .map_err(|e| anyhow!("collection is not valid JSON (YAML specs must be converted to JSON first): {e}"))?; + let mut out = match detect(&v) { + "har" => from_har(&v), + "openapi" => from_openapi(&v), + "postman" => from_postman(&v), + _ => return Err(anyhow!("unrecognised collection format — expected a Postman collection, an OpenAPI/Swagger spec, or a HAR capture (JSON)")), + }; + dedup(&mut out); + if out.is_empty() { + return Err(anyhow!("no endpoints found in the collection")); + } + Ok(out) +} + +fn dedup(items: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + items.retain(|e| seen.insert((e.method.clone(), e.url.clone()))); +} + +// ---- Postman v2.x -------------------------------------------------------- + +fn from_postman(v: &Value) -> Vec { + // Collection variables (incl. the common {{baseUrl}}) for substitution. + let mut vars = std::collections::HashMap::new(); + if let Some(arr) = v.get("variable").and_then(|x| x.as_array()) { + for it in arr { + if let (Some(k), Some(val)) = (it.get("key").and_then(|x| x.as_str()), it.get("value").and_then(|x| x.as_str())) { + vars.insert(k.to_string(), val.to_string()); + } + } + } + let mut out = Vec::new(); + if let Some(items) = v.get("item").and_then(|x| x.as_array()) { + for it in items { + walk_postman(it, &vars, &mut out); + } + } + out +} + +fn walk_postman(item: &Value, vars: &std::collections::HashMap, out: &mut Vec) { + // A folder has its own `item[]`; recurse. + if let Some(children) = item.get("item").and_then(|x| x.as_array()) { + for c in children { + walk_postman(c, vars, out); + } + return; + } + let Some(req) = item.get("request") else { return }; + let method = req.get("method").and_then(|x| x.as_str()).unwrap_or("GET").to_uppercase(); + let url = postman_url(req.get("url"), vars); + if url.is_empty() { + return; + } + let note = item.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(); + out.push(SeedEndpoint { method, url: subst_vars(&url, vars), note }); +} + +fn postman_url(url: Option<&Value>, vars: &std::collections::HashMap) -> String { + match url { + Some(Value::String(s)) => s.clone(), + Some(o @ Value::Object(_)) => { + if let Some(raw) = o.get("raw").and_then(|x| x.as_str()) { + return raw.to_string(); + } + // Reconstruct host + path when `raw` is absent. + let host = join_parts(o.get("host"), "."); + let path = join_parts(o.get("path"), "/"); + let scheme = o.get("protocol").and_then(|x| x.as_str()).unwrap_or("https"); + if host.is_empty() { return String::new(); } + let _ = vars; + format!("{scheme}://{host}/{path}") + } + _ => String::new(), + } +} + +fn join_parts(v: Option<&Value>, sep: &str) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).collect::>().join(sep), + _ => String::new(), + } +} + +fn subst_vars(s: &str, vars: &std::collections::HashMap) -> String { + let mut out = s.to_string(); + for (k, val) in vars { + out = out.replace(&format!("{{{{{k}}}}}"), val); + } + out +} + +// ---- OpenAPI 3.x / Swagger 2.0 ------------------------------------------ + +fn from_openapi(v: &Value) -> Vec { + // Base URL: OpenAPI 3 `servers[0].url`, or Swagger 2 `schemes`+`host`+`basePath`. + let base = v.get("servers") + .and_then(|s| s.as_array()) + .and_then(|a| a.first()) + .and_then(|s| s.get("url")) + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + let scheme = v.get("schemes").and_then(|x| x.as_array()).and_then(|a| a.first()).and_then(|x| x.as_str()).unwrap_or("https"); + let host = v.get("host").and_then(|x| x.as_str()).unwrap_or(""); + let bp = v.get("basePath").and_then(|x| x.as_str()).unwrap_or(""); + if host.is_empty() { String::new() } else { format!("{scheme}://{host}{bp}") } + }); + let base = base.trim_end_matches('/').to_string(); + const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"]; + let mut out = Vec::new(); + if let Some(paths) = v.get("paths").and_then(|x| x.as_object()) { + for (path, ops) in paths { + let Some(ops) = ops.as_object() else { continue }; + for (m, op) in ops { + if !METHODS.contains(&m.to_lowercase().as_str()) { + continue; + } + let note = op.get("summary").or_else(|| op.get("operationId")).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let url = if base.is_empty() { path.clone() } else { format!("{base}{path}") }; + out.push(SeedEndpoint { method: m.to_uppercase(), url, note }); + } + } + } + out +} + +// ---- HAR 1.2 ------------------------------------------------------------- + +fn from_har(v: &Value) -> Vec { + let mut out = Vec::new(); + if let Some(entries) = v.pointer("/log/entries").and_then(|x| x.as_array()) { + for e in entries { + let Some(req) = e.get("request") else { continue }; + let method = req.get("method").and_then(|x| x.as_str()).unwrap_or("GET").to_uppercase(); + let url = req.get("url").and_then(|x| x.as_str()).unwrap_or("").to_string(); + if url.is_empty() { + continue; + } + let note = req.get("postData").and_then(|p| p.get("mimeType")).and_then(|x| x.as_str()).unwrap_or("").to_string(); + out.push(SeedEndpoint { method, url, note }); + } + } + out +} + +// ---- rendering ----------------------------------------------------------- + +/// Render the endpoint list as a prompt block injected into every agent, so the +/// imported surface is tested directly. Capped so a huge collection can't blow +/// the context budget. +pub fn render_block(endpoints: &[SeedEndpoint], cap: usize) -> String { + if endpoints.is_empty() { + return String::new(); + } + let shown = endpoints.len().min(cap); + let mut s = String::new(); + s.push_str(&format!( + "IMPORTED API SURFACE — the operator supplied {} known endpoint(s) from an API collection (Postman/OpenAPI/HAR). TEST EVERY ONE of these in addition to what recon finds; they are in scope and often include undocumented or auth-gated routes recon would miss:\n", + endpoints.len() + )); + for e in endpoints.iter().take(cap) { + if e.note.trim().is_empty() { + s.push_str(&format!(" - {} {}\n", e.method, e.url)); + } else { + s.push_str(&format!(" - {} {} ({})\n", e.method, e.url, e.note.trim())); + } + } + if endpoints.len() > shown { + s.push_str(&format!(" … and {} more (see the collection file)\n", endpoints.len() - shown)); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_postman_v21_with_folders_and_vars() { + let raw = r#"{ + "info": {"name": "api"}, + "variable": [{"key": "baseUrl", "value": "https://api.example.com"}], + "item": [ + {"name": "Auth", "item": [ + {"name": "login", "request": {"method": "POST", "url": {"raw": "{{baseUrl}}/api/auth/login"}}} + ]}, + {"name": "search", "request": {"method": "GET", "url": {"raw": "{{baseUrl}}/api/search?q=x"}}} + ] + }"#; + let eps = parse_str(raw).unwrap(); + assert_eq!(eps.len(), 2); + assert!(eps.iter().any(|e| e.method == "POST" && e.url == "https://api.example.com/api/auth/login")); + assert!(eps.iter().any(|e| e.method == "GET" && e.url == "https://api.example.com/api/search?q=x")); + } + + #[test] + fn parses_openapi3() { + let raw = r#"{ + "openapi": "3.0.0", + "servers": [{"url": "https://api.example.com/v1"}], + "paths": { + "/users": {"get": {"summary": "list"}, "post": {"operationId": "create"}}, + "/users/{id}": {"delete": {}} + } + }"#; + let eps = parse_str(raw).unwrap(); + assert_eq!(eps.len(), 3); + assert!(eps.iter().any(|e| e.method == "GET" && e.url == "https://api.example.com/v1/users")); + assert!(eps.iter().any(|e| e.method == "DELETE" && e.url == "https://api.example.com/v1/users/{id}")); + } + + #[test] + fn parses_swagger2() { + let raw = r#"{ + "swagger": "2.0", + "host": "api.example.com", + "basePath": "/v2", + "schemes": ["https"], + "paths": {"/ping": {"get": {}}} + }"#; + let eps = parse_str(raw).unwrap(); + assert_eq!(eps, vec![SeedEndpoint { method: "GET".into(), url: "https://api.example.com/v2/ping".into(), note: String::new() }]); + } + + #[test] + fn parses_har_and_dedups() { + let raw = r#"{"log": {"entries": [ + {"request": {"method": "GET", "url": "https://api.example.com/a"}}, + {"request": {"method": "GET", "url": "https://api.example.com/a"}}, + {"request": {"method": "POST", "url": "https://api.example.com/b", "postData": {"mimeType": "application/json"}}} + ]}}"#; + let eps = parse_str(raw).unwrap(); + assert_eq!(eps.len(), 2, "duplicate GET /a collapses"); + } + + #[test] + fn rejects_unknown_and_empty() { + assert!(parse_str(r#"{"hello": "world"}"#).is_err()); + assert!(parse_str(r#"{"openapi": "3.0.0", "paths": {}}"#).is_err()); + assert!(parse_str("not json").is_err()); + } + + #[test] + fn render_caps_long_lists() { + let eps: Vec = (0..300).map(|i| SeedEndpoint { method: "GET".into(), url: format!("https://x/{i}"), note: String::new() }).collect(); + let block = render_block(&eps, 200); + assert!(block.contains("300 known endpoint")); + assert!(block.contains("and 100 more")); + } +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index 17ba7a8..4010b00 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -17,6 +17,7 @@ pub mod budget; pub mod capability; pub mod chain; pub mod claims; +pub mod collection; pub mod compliance; pub mod creds; pub mod cvss; diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 1deb504..9a0754b 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -176,6 +176,14 @@ fn operator_directives(cfg: &RunConfig) -> String { if let Some(auth) = cfg.auth.as_deref().filter(|x| !x.trim().is_empty()) { s.push_str(&format!("AUTHENTICATION — test as an authenticated user; send this with each request: {auth}\n")); } + // Operator-supplied API collection (Postman/OpenAPI/HAR): inject the known + // endpoints as surface to test directly. Parse errors are reported once at + // launch (see the CLI), so render silently here. + if let Some(col) = cfg.api_collection.as_deref().filter(|x| !x.trim().is_empty()) { + if let Ok(eps) = crate::collection::parse_file(col) { + s.push_str(&crate::collection::render_block(&eps, 200)); + } + } // What the deterministic engine needs in order to confirm a class. Agents // hold the target *now*; asking for a baseline/attack pair or a marker // after the run is asking for something that no longer exists. diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index daea726..e9342c1 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -303,6 +303,11 @@ pub struct RunConfig { /// gets the full run. #[serde(default)] pub budget: crate::budget::Budget, + /// Path to an API collection (Postman / OpenAPI / HAR, JSON) whose endpoints + /// are imported as known in-scope surface and injected into every agent so + /// they are tested directly, in addition to what recon discovers. + #[serde(default)] + pub api_collection: Option, } fn default_vote() -> usize { @@ -363,6 +368,7 @@ impl RunConfig { authz_matrix: None, creds_file: None, neo4j: None, + api_collection: None, } } }