identification/attribution + multi-role access-control auth (v3.5.5)

Attribution (anti-plagiarism), multiple layers:
- Identifying User-Agent on every request (default NeuroSploit/<ver> + an
  X-NeuroSploit-Scan header), overridable via /ua or NEUROSPLOIT_UA env; shown
  in the run banner. RunConfig.user_agent + Session.user_agent wired through.
- Every finding is stamped "Identified and validated by NeuroSploit …" (in
  finish() and the raw-report path) so provenance travels in the finding text,
  findings.json and the report.

Multi-role authentication for access-control testing (IDOR/BOLA/BFLA/privesc):
- creds.yaml gains named identity blocks (admin:/user:/victim:/…), each with
  jwt | header | cookie | apikey | login+username+password. With >=2 roles the
  harness injects a cross-role access-control directive (authorized-vs-unauthorized
  proof) and defaults the primary auth to the first role.

Also: /help now lists one command per line (fixes smushed OPTIONS/RUN columns);
/ua command + Session field; docs (README + RELEASE) updated.
This commit is contained in:
CyberSecurityUP
2026-07-01 23:59:02 -03:00
parent f303d10d76
commit 0b616b407d
7 changed files with 216 additions and 6 deletions
+33
View File
@@ -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/<ver> …`, 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
+29
View File
@@ -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/<ver> (authorized security assessment; +github…)`, plus an
`X-NeuroSploit-Scan` header. Change it with **`/ua <string>`** (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).
+18
View File
@@ -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::<Vec<_>>().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);
+17 -2
View File
@@ -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<String>,
/// Identifying User-Agent for NeuroSploit traffic (None = default UA).
user_agent: Option<String>,
offline: bool,
target: Option<String>,
repo: Option<String>,
@@ -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 <url>, e.g. /proxy http://127.0.0.1:8080".into())),
@@ -755,6 +766,7 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
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 <url[,..]>", "black-box target URL (comma-separated = multi-target, sequential)");
h("/repo <path|url>", "analyse a repo — path or GitHub URL (repo + target = greybox)");
h("/auth <value>", "auth header, e.g. 'Authorization: Bearer <jwt>' (no arg = show)");
h("/creds <file.yaml>", "credentials: jwt/header/cookie/login + ssh/windows + aws/gcp/azure");
h("/creds <file.yaml>", "creds: jwt/header/cookie/login + ssh/windows + aws/gcp/azure + roles");
h("/focus <text>", "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 <path>", "attach a file/folder to context");
@@ -1312,6 +1326,7 @@ fn help() {
h("/chain <n>", "attack-chain depth (post-exploitation pivots; 0 = off)");
h("/timeout <min>", "idle guardrail: stop if no new finding in <min> (0 = off)");
h("/proxy <url>|off", "route agent HTTP through Burp/ZAP (/burp = default :8080)");
h("/ua <string>", "identifying User-Agent for NeuroSploit traffic (default = NeuroSploit)");
h("/agents <n>|list", "cap agents to run · `list` shows library counts");
h("/theme color|mono", "toggle colored output");
h("/show", "show the current session config");
+76 -3
View File
@@ -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 <jwt>
pub header: String, // raw header, e.g. "X-Api-Key: abc"
pub cookie: String, // → Cookie: <cookie>
pub apikey: String, // → X-Api-Key: <apikey> (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<String> {
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<String>,
@@ -89,6 +121,8 @@ pub struct Creds {
pub ssh: Option<Ssh>,
pub win: Option<Win>,
pub cloud: Option<Cloud>,
/// Named identities for multi-role access-control testing.
pub roles: Vec<Identity>,
}
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<Identity> = 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<String> {
if self.roles.len() < 2 { return None; }
let list = self.roles.iter().map(|r| format!(" - {}", r.describe())).collect::<Vec<_>>().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.
+38 -1
View File
@@ -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);
@@ -133,6 +133,10 @@ pub struct RunConfig {
/// traffic in Burp Suite.
#[serde(default)]
pub proxy: Option<String>,
/// Custom User-Agent for identifying NeuroSploit traffic (attribution).
/// Defaults to the NeuroSploit UA when unset.
#[serde(default)]
pub user_agent: Option<String>,
}
fn default_vote() -> usize {
@@ -165,6 +169,7 @@ impl RunConfig {
pinned: Vec::new(),
chain_depth: 2,
proxy: None,
user_agent: None,
}
}
}