diff --git a/README.md b/README.md index 34ba0aa..cfd9578 100755 --- a/README.md +++ b/README.md @@ -243,6 +243,39 @@ auth: **AWS** access keys or profile; **GCP** a service-account JSON --- +## πŸ‘₯ Multiple identities β€” access-control testing (IDOR / BOLA / BFLA) + +Give NeuroSploit two or more **named roles** in `creds.yaml` and it authenticates +as each and tests **cross-role** access (a low-priv role reaching another user's +object or an admin function is a finding): + +```yaml +admin: + jwt: eyJ... # per role: jwt | header (raw) | cookie | apikey | login+username+password +user: + apikey: abc123 # β†’ X-Api-Key: abc123 +victim: + cookie: "session=deadbeef" +``` + +```bash +neurosploit run https://app.example --creds creds.yaml \ + --subscription --model anthropic:claude-opus-4-8 -v +``` + +Each finding is proven with the **authorized vs unauthorized** request pair, under +the data-safety guardrail (read-only, PII masked). + +## 🏷️ Identification & attribution (anti-plagiarism) + +Every request is tagged with an identifying **User-Agent** (default +`NeuroSploit/ …`, change with **`/ua`** or `NEUROSPLOIT_UA`) plus an +`X-NeuroSploit-Scan` header, and every finding is **stamped** "Identified and +validated by NeuroSploit" β€” so provenance travels in the traffic, the finding +text, `findings.json` and the report footer. + +--- + ## Build ```bash diff --git a/RELEASE.md b/RELEASE.md index ffa5bcd..f80356e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -92,6 +92,35 @@ interactive line-editing. - **Rate-limit testing** is a first-class control check (small non-disruptive burst β†’ look for 429/lockout/Retry-After), never a DoS. +## Multi-role auth & access-control testing + +- **Named identities in `creds.yaml`** for IDOR / BOLA / BFLA / privilege-escalation + testing. Define two or more roles and the agent authenticates as each and tests + **cross-role access** (control vs unauthorized request): + ```yaml + admin: + jwt: eyJ... # or header:/cookie:/apikey:/login+username+password + user: + apikey: abc123 # β†’ X-Api-Key: abc123 + victim: + cookie: "session=..." + ``` + Supported per role: `jwt`, `header` (raw), `cookie`, `apikey`, or a + `login`/`username`/`password` self-login. With β‰₯2 roles the harness injects an + access-control directive (capture one role's object IDs/functions, attempt them + as another role, prove authorized-vs-denied) under the data-safety guardrail. + +## Attribution & identification (anti-plagiarism) + +- **Identifying User-Agent** on every request β€” default + `NeuroSploit/ (authorized security assessment; +github…)`, plus an + `X-NeuroSploit-Scan` header. Change it with **`/ua `** (REPL) or the + `NEUROSPLOIT_UA` env var; the run banner shows it. +- **Attribution stamped into every finding** ("Identified and validated by + NeuroSploit β€” multi-model adversarial validation …") so provenance travels with + the finding across the report, `findings.json` and any copy β€” in the traffic, + the finding text, and the report footer, so the work can't be silently re-badged. + ## Notes - Additive/back-compatible. Provider count is 14 (Azure OpenAI added in v3.5.2). diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 2737593..2ac6833 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -467,6 +467,16 @@ pub(crate) async fn apply_creds(cfg: &mut RunConfig, path: Option<&str>) { if cfg.auth.is_none() { cfg.auth = c.auth_header(); } + // Multiple identities/roles β†’ access-control testing (IDOR/BOLA/BFLA/privesc). + if let Some(ri) = c.roles_instruction() { + if cfg.auth.is_none() { + cfg.auth = c.roles.iter().find_map(|r| r.header_line()); + } + let base = cfg.instructions.clone().unwrap_or_default(); + cfg.instructions = Some(format!("{ri}\n{base}")); + println!(" [*] {} identities loaded ({}) β€” access-control testing enabled", + c.roles.len(), c.roles.iter().map(|r| r.name.clone()).collect::>().join("/")); + } // Host credentials (SSH / Windows-AD) β†’ tell the agents how to authenticate // to the host so they can run on-host enumeration / privesc / AD checks. if let Some(hi) = c.host_instruction() { @@ -563,6 +573,13 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode: std::env::set_var("NEUROSPLOIT_PROXY", &p); println!(" β”‚ proxy : {p} (traffic routed to Burp/ZAP for inspection)"); } + // Identifying User-Agent (attribution): cfg.user_agent overrides the default. + let ua = cfg.user_agent.clone() + .or_else(|| std::env::var("NEUROSPLOIT_UA").ok()) + .filter(|u| !u.trim().is_empty()) + .unwrap_or_else(harness::pipeline::default_user_agent); + std::env::set_var("NEUROSPLOIT_UA", &ua); + println!(" β”‚ ua : {ua}"); write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target)); println!(" β”Œβ”€ NeuroSploit v3.5.5 Β· by Joas A Santos & Red Team Leaders"); @@ -629,6 +646,7 @@ pub(crate) fn report_url(workdir: &Path) -> String { /// when the user chooses "report without validating" on /stop. pub(crate) fn report_raw(target: &str, findings: &[harness::types::Finding], workdir: &Path) { let mut fs = findings.to_vec(); + harness::pipeline::stamp_attribution(&mut fs); // provenance travels with raw reports too harness::attack_graph::enrich(&mut fs); std::fs::write(workdir.join("findings.json"), serde_json::to_string_pretty(&fs).unwrap_or_default()).ok(); let _ = harness::report::typst_report(target, &fs, workdir); diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index b1bb731..ece30f6 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -119,7 +119,7 @@ struct LiveCheckpoint { const COMMANDS: &[&str] = &[ "/help", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target", "/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline", - "/votes", "/chain", "/timeout", "/proxy", "/burp", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", + "/votes", "/chain", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", "/status", "/diff", "/retest", "/finding", "/expand", "/integrations", "/quit", ]; @@ -219,6 +219,8 @@ struct Session { idle_secs: u64, /// Local intercepting proxy (Burp/ZAP), e.g. http://127.0.0.1:8080. proxy: Option, + /// Identifying User-Agent for NeuroSploit traffic (None = default UA). + user_agent: Option, offline: bool, target: Option, repo: Option, @@ -240,6 +242,7 @@ impl Default for Session { chain_depth: 2, idle_secs: 300, // 5-minute idle guardrail by default proxy: None, + user_agent: None, offline: false, target: None, repo: None, @@ -441,6 +444,14 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { else { println!(" idle guardrail: stop if no new finding in {mins} min"); } } } + "/ua" | "/useragent" => { + match arg { + "" => println!(" user-agent: {} \x1b[2m(identifies NeuroSploit traffic)\x1b[0m", + s.user_agent.clone().unwrap_or_else(harness::pipeline::default_user_agent)), + "default" | "reset" => { s.user_agent = None; println!(" user-agent reset to default (NeuroSploit)"); } + u => { s.user_agent = Some(u.to_string()); println!(" user-agent: {u}"); } + } + } "/proxy" | "/burp" => { match arg { "" => println!(" proxy: {}", s.proxy.clone().unwrap_or_else(|| "(none) β€” route traffic to Burp/ZAP with /proxy , e.g. /proxy http://127.0.0.1:8080".into())), @@ -755,6 +766,7 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { cfg.vote_n = s.vote_n; cfg.chain_depth = s.chain_depth; cfg.proxy = s.proxy.clone(); + cfg.user_agent = s.user_agent.clone(); cfg.max_agents = s.max_agents; cfg.verbose = true; cfg.offline = s.offline; @@ -809,6 +821,7 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader, cfg.vote_n = s.vote_n; cfg.chain_depth = s.chain_depth; cfg.proxy = s.proxy.clone(); + cfg.user_agent = s.user_agent.clone(); cfg.max_agents = s.max_agents; cfg.verbose = true; cfg.offline = s.offline; @@ -1243,6 +1256,7 @@ fn show(s: &Session) { println!(" β”‚ auth : {}", s.auth.clone().unwrap_or_else(|| "(none)".into())); println!(" β”‚ creds : {}", s.creds.clone().unwrap_or_else(|| "(none)".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())); println!(" β”‚ opts : mcp={} offline={} votes={} chain-depth={} max-agents={} idle-stop={}", onoff(s.mcp), onoff(s.offline), s.vote_n, s.chain_depth, s.max_agents, @@ -1278,7 +1292,7 @@ fn help() { h("/target ", "black-box target URL (comma-separated = multi-target, sequential)"); h("/repo ", "analyse a repo β€” path or GitHub URL (repo + target = greybox)"); h("/auth ", "auth header, e.g. 'Authorization: Bearer ' (no arg = show)"); - h("/creds ", "credentials: jwt/header/cookie/login + ssh/windows + aws/gcp/azure"); + h("/creds ", "creds: jwt/header/cookie/login + ssh/windows + aws/gcp/azure + roles"); h("/focus ", "steer the tests (or just type the instruction)"); h("@path @dir @f:1-20", "attach a file/folder/line-range to context (Tab β†’ menu)"); h("/attach ", "attach a file/folder to context"); @@ -1312,6 +1326,7 @@ fn help() { h("/chain ", "attack-chain depth (post-exploitation pivots; 0 = off)"); h("/timeout ", "idle guardrail: stop if no new finding in (0 = off)"); h("/proxy |off", "route agent HTTP through Burp/ZAP (/burp = default :8080)"); + h("/ua ", "identifying User-Agent for NeuroSploit traffic (default = NeuroSploit)"); h("/agents |list", "cap agents to run Β· `list` shows library counts"); h("/theme color|mono", "toggle colored output"); h("/show", "show the current session config"); diff --git a/neurosploit-rs/crates/harness/src/creds.rs b/neurosploit-rs/crates/harness/src/creds.rs index 1916aa0..f27430d 100644 --- a/neurosploit-rs/crates/harness/src/creds.rs +++ b/neurosploit-rs/crates/harness/src/creds.rs @@ -80,6 +80,38 @@ impl Cloud { } } +/// A named identity/role for multi-user access-control testing (IDOR / BOLA / +/// BFLA / privilege escalation). Each carries ONE way to authenticate. +#[derive(Default, Debug, Clone)] +pub struct Identity { + pub name: String, // e.g. "admin", "user", "victim" + pub jwt: String, // β†’ Authorization: Bearer + pub header: String, // raw header, e.g. "X-Api-Key: abc" + pub cookie: String, // β†’ Cookie: + pub apikey: String, // β†’ X-Api-Key: (unless it contains ':') + pub login_url: String, // login endpoint (agent authenticates itself) + pub username: String, + pub password: String, +} + +impl Identity { + /// The ready-to-send auth header for this identity, if it has direct material. + pub fn header_line(&self) -> Option { + if !self.header.is_empty() { return Some(self.header.clone()); } + if !self.jwt.is_empty() { return Some(format!("Authorization: Bearer {}", self.jwt)); } + if !self.apikey.is_empty() { + return Some(if self.apikey.contains(':') { self.apikey.clone() } else { format!("X-Api-Key: {}", self.apikey) }); + } + if !self.cookie.is_empty() { return Some(format!("Cookie: {}", self.cookie)); } + None + } + fn describe(&self) -> String { + if let Some(h) = self.header_line() { format!("{} β†’ send `{}`", self.name, h) } + else if !self.login_url.is_empty() { format!("{} β†’ log in at {} as {}:{} and reuse the session", self.name, self.login_url, self.username, self.password) } + else { format!("{} β†’ (no usable credential)", self.name) } + } +} + #[derive(Default, Debug, Clone)] pub struct Creds { pub jwt: Option, @@ -89,6 +121,8 @@ pub struct Creds { pub ssh: Option, pub win: Option, pub cloud: Option, + /// Named identities for multi-role access-control testing. + pub roles: Vec, } impl Creds { @@ -100,7 +134,9 @@ impl Creds { let mut win = Win::default(); let mut cloud = Cloud::default(); let (mut have_login, mut have_ssh, mut have_win) = (false, false, false); - let mut block = ""; // "", "login", "ssh", "windows", "aws", "gcp", "azure" + let mut roles: Vec = Vec::new(); + let mut cur_role = 0usize; + let mut block = ""; // "", "login", "ssh", "windows", "aws", "gcp", "azure", "role" for raw in text.lines() { let line = raw.split('#').next().unwrap_or(""); if line.trim().is_empty() { @@ -120,7 +156,9 @@ impl Creds { "aws" => "aws", "gcp" | "google" | "gcloud" => "gcp", "azure" | "az" => "azure", - _ => "", + "roles" | "identities" | "users" => "", // optional wrapper β€” ignore + // Any other named block is a role/identity for access-control testing. + other => { roles.push(Identity { name: other.to_string(), ..Default::default() }); cur_role = roles.len() - 1; "role" } }; continue; } @@ -172,6 +210,18 @@ impl Creds { "subscription_id" | "subscription" => cloud.azure_subscription_id = v, _ => {} }, + "role" => if let Some(r) = roles.get_mut(cur_role) { + match k.as_str() { + "jwt" | "token" => r.jwt = v, + "header" => r.header = v, + "cookie" => r.cookie = v, + "apikey" | "api_key" | "key" => r.apikey = v, + "login" | "url" | "login_url" => r.login_url = v, + "username" | "user" => r.username = v, + "password" | "pass" => r.password = v, + _ => {} + } + }, _ => {} } continue; @@ -188,13 +238,36 @@ impl Creds { if have_ssh && !ssh.host.is_empty() { c.ssh = Some(ssh); } if have_win && !win.host.is_empty() { c.win = Some(win); } if !cloud.is_empty() { c.cloud = Some(cloud); } + roles.retain(|r| r.header_line().is_some() || !r.login_url.is_empty()); + c.roles = roles; if c.jwt.is_none() && c.header.is_none() && c.cookie.is_none() - && c.login.is_none() && c.ssh.is_none() && c.win.is_none() && c.cloud.is_none() { + && c.login.is_none() && c.ssh.is_none() && c.win.is_none() && c.cloud.is_none() + && c.roles.is_empty() { return None; } Some(c) } + /// Multi-role access-control testing directive: lists every identity and + /// instructs the agent to test cross-role access (IDOR/BOLA, BFLA, privesc) + /// by acting as each role against the others' objects and functions. + pub fn roles_instruction(&self) -> Option { + if self.roles.len() < 2 { return None; } + let list = self.roles.iter().map(|r| format!(" - {}", r.describe())).collect::>().join("\n"); + Some(format!( + "MULTI-ROLE ACCESS CONTROL β€” you have {} identities:\n{list}\n\ + Authenticate as EACH identity (use its header on every request, or log in first for a login: role and \ + reuse the session). Then test broken access control across roles:\n\ + - BOLA/IDOR: as a low-privilege role, capture your own object IDs, then try to READ/UPDATE another \ + role's objects by their IDs; a low-priv role reaching a high-priv/other-user object is a finding.\n\ + - BFLA: call admin-only functions/endpoints/HTTP methods with a low-privilege role's session.\n\ + - Privilege escalation: mass-assignment of role/permission fields, or reaching admin routes.\n\ + Always compare against the control (the authorized role should succeed; the unauthorized role should be \ + denied). Prove each with the two requests (authorized vs unauthorized) and their responses. Respect data \ + safety β€” read-only proof, mask any PII.\n", + self.roles.len())) + } + /// Environment variables to export so the `aws`/`gcloud`/`az` CLIs the agents /// run pick up the cloud credentials automatically. For inline GCP JSON the /// content is written to a temp file and that path is returned. diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 4ba0a4d..13f5e56 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -85,13 +85,48 @@ fn tool_doctrine(mcp_on: bool) -> String { repo or download a tool (`git clone`, `wget`, `pip install`, `go install`, `cargo install`) β€” use pinned, \ reputable sources; review before running; never run destructive payloads.\n\ - {browser}\n\ - - {proxy}{pocs}\ + - {ua}{proxy}{pocs}\ Use only what is installed; degrade gracefully. Never run destructive or DoS actions.\n\n", + ua = ua_line(), proxy = proxy_line(), pocs = pocs_line(), ) } +/// Default identifying User-Agent so target owners (and the operator) can tell +/// traffic came from NeuroSploit β€” and so authorship of a scan is unambiguous. +pub fn default_user_agent() -> String { + format!("NeuroSploit/{} (authorized security assessment; +https://github.com/JoasASantos/NeuroSploit)", + env!("CARGO_PKG_VERSION")) +} + +/// Identify NeuroSploit traffic at the request layer (User-Agent + a marker +/// header). Overridable via `NEUROSPLOIT_UA`. +fn ua_line() -> String { + let ua = std::env::var("NEUROSPLOIT_UA").ok().filter(|v| !v.trim().is_empty()) + .unwrap_or_else(default_user_agent); + format!( + "IDENTIFY (attribution β€” do NOT strip): tag every HTTP request as NeuroSploit so the scan is \ + attributable β€” add `-A \"{ua}\"` (User-Agent) AND `-H \"X-NeuroSploit-Scan: {}\"` to curl. Only omit \ + when a specific test requires a different/absent User-Agent.\n ", + env!("CARGO_PKG_VERSION")) +} + +/// Attribution stamped into every finding's impact so the provenance travels +/// with the finding across the report, findings.json and any copy β€” making it +/// hard to silently re-badge NeuroSploit's output as someone else's work. +const ATTRIBUTION: &str = "Identified and validated by NeuroSploit (multi-model adversarial validation) β€” https://github.com/JoasASantos/NeuroSploit Β· by Joas A Santos & Red Team Leaders."; + +/// Append the NeuroSploit attribution to each finding's impact (idempotent). +pub fn stamp_attribution(findings: &mut [Finding]) { + for f in findings.iter_mut() { + if !f.impact.contains("Identified and validated by NeuroSploit") { + let sep = if f.impact.trim().is_empty() { "" } else { "\n\n" }; + f.impact = format!("{}{sep}{ATTRIBUTION}", f.impact.trim_end()); + } + } +} + /// If a local proxy is configured (Burp/ZAP), tell agents to route HTTP through /// it so the operator can inspect/replay traffic in Burp Suite. fn proxy_line() -> String { @@ -878,6 +913,8 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin } let _ = tx.send(format!("{} validated finding(s)", findings.len())).await; + // Attribution: stamp provenance into each finding (report + json + copies). + stamp_attribution(&mut findings); // Map findings to OWASP / MITRE / kill-chain stage for the attack graph. crate::attack_graph::enrich(&mut findings); diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index 31bb544..03d2324 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -133,6 +133,10 @@ pub struct RunConfig { /// traffic in Burp Suite. #[serde(default)] pub proxy: Option, + /// Custom User-Agent for identifying NeuroSploit traffic (attribution). + /// Defaults to the NeuroSploit UA when unset. + #[serde(default)] + pub user_agent: Option, } fn default_vote() -> usize { @@ -165,6 +169,7 @@ impl RunConfig { pinned: Vec::new(), chain_depth: 2, proxy: None, + user_agent: None, } } }