mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-19 14:07:16 +02:00
feat(collection): import a Postman/OpenAPI/HAR collection as test surface
Operators often already have the API mapped. Add a collection importer so a Postman collection, an OpenAPI 3/Swagger 2 spec, or a HAR capture (JSON) is parsed into a flat endpoint list and injected into every agent as known in-scope surface — tested directly, alongside what recon finds, so documented and auth-gated routes recon might miss are still covered. - new harness module `collection` (format auto-detected by shape; de-duped; render capped so a huge collection can't blow the prompt budget) + 6 tests - RunConfig.api_collection; injected in operator_directives - CLI: global `--collection <file>` on run/greybox/aitest, validated at launch - REPL: `/collection <file>` (and `/import-api`), persisted in the session, shown in /status, added to help + completion Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
0aaac20a0d
commit
7a0b2cb244
6 files changed
+363
-3
No files matched your search
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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
|
||||
|
||||
@@ -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<String>,
|
||||
/// Imported API collection (Postman/OpenAPI/HAR) path — tested as surface.
|
||||
collection: Option<String>,
|
||||
instructions: Option<String>,
|
||||
/// Engagement objective / rules-of-engagement context (why + what matters).
|
||||
objective: Option<String>,
|
||||
@@ -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 <file> (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<RunRecord>) {
|
||||
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<String>,
|
||||
auth: Option<String>,
|
||||
creds: Option<String>,
|
||||
#[serde(default)]
|
||||
collection: Option<String>,
|
||||
instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
objective: Option<String>,
|
||||
@@ -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 <path|url>", "source repo (greybox) OR skill/n8n file/folder to audit (skills scope)");
|
||||
h("/auth <value>", "auth header (Bearer/cookie/key). Roles: /auth admin <hdr> · /auth user <hdr>");
|
||||
h("/creds <file.yaml>", "creds: jwt/header/cookie/login + ssh/windows + aws/gcp/azure + roles");
|
||||
h("/collection <file>", "import an API collection (Postman/OpenAPI/HAR, JSON) as known surface to test");
|
||||
h("/focus <text>", "steer the tests (or just type the instruction)");
|
||||
h("/objective <text>", "engagement goal/context — shapes what agents prioritise & count as impact");
|
||||
h("/scope-out <text>", "out-of-scope exclusions — host-shaped entries become ENFORCED denials");
|
||||
|
||||
@@ -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<Vec<SeedEndpoint>> {
|
||||
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<Vec<SeedEndpoint>> {
|
||||
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<SeedEndpoint>) {
|
||||
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<SeedEndpoint> {
|
||||
// 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<String, String>, out: &mut Vec<SeedEndpoint>) {
|
||||
// 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, String>) -> 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::<Vec<_>>().join(sep),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn subst_vars(s: &str, vars: &std::collections::HashMap<String, String>) -> 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<SeedEndpoint> {
|
||||
// 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<SeedEndpoint> {
|
||||
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<SeedEndpoint> = (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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
fn default_vote() -> usize {
|
||||
@@ -363,6 +368,7 @@ impl RunConfig {
|
||||
authz_matrix: None,
|
||||
creds_file: None,
|
||||
neo4j: None,
|
||||
api_collection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user