mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-14 13:40:23 +02:00
feat: account registration, form analysis, credential vault + cleanup (v3.6.5)
- New agent account_registration_and_forms (+1 → 430): analyzes the app's forms and self-registers a benign test account (curl or Playwright) to reach the authenticated surface when no creds are given. - Probe extracts form details (action/method/fields/kind/CSRF) so form analysis is grounded; shown in the probe summary and recon JSON. - Hard anti-flood guardrail in SAFETY_DOCTRINE + the agent: at most 2 accounts per engagement, never loop/script/batch the register endpoint or flood the DB; reuse the account made; a test needing many sign-ups is a lead, not mass-creation. - Credential vault: engagement_ops directive tells agents to append created accounts to <run-dir>/vault.jsonl; finish() consolidates to vault.json, masks secrets in the report, and adds a 'Test accounts created (DELETE after)' cleanup finding listing each account and how it was created. - Finding tagging: new auth_context (authenticated/unauthenticated) and account fields, rendered per-finding in the HTML report. - Opt-in disposable email (off by default): /tempmail on + RunConfig.temp_email; agents may use the free mail.tm API to read a registration confirmation code. - Tests: parse_forms unit tests; docs updated (README/TUTORIAL/RELEASE), counts 430.
This commit is contained in:
@@ -142,7 +142,7 @@ struct LiveCheckpoint {
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help", "/onboard", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target",
|
||||
"/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline",
|
||||
"/votes", "/chain", "/recon", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
||||
"/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
||||
"/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations", "/quit",
|
||||
];
|
||||
|
||||
@@ -238,6 +238,8 @@ struct Session {
|
||||
max_agents: usize,
|
||||
chain_depth: usize,
|
||||
recon_intensity: usize,
|
||||
/// Opt-in disposable email (mail.tm) for register flows needing a confirmation code.
|
||||
temp_email: bool,
|
||||
/// Idle guardrail: stop a run if no NEW finding lands in this many seconds
|
||||
/// (0 = disabled). Set in minutes via `/timeout <mins>`.
|
||||
idle_secs: u64,
|
||||
@@ -269,6 +271,7 @@ impl Default for Session {
|
||||
max_agents: 0,
|
||||
chain_depth: 2,
|
||||
recon_intensity: 3,
|
||||
temp_email: false,
|
||||
idle_secs: 300, // 5-minute idle guardrail by default
|
||||
proxy: None,
|
||||
user_agent: None,
|
||||
@@ -602,6 +605,13 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
if arg.is_empty() { println!(" recon intensity: {} ({}) — set with /recon <1-4> [1 quick · 2 standard · 3 deep · 4 exhaustive]", s.recon_intensity, lvl(s.recon_intensity)); }
|
||||
else { s.recon_intensity = arg.parse::<usize>().unwrap_or(s.recon_intensity).clamp(1, 4); println!(" recon intensity: {} ({}) — more rounds, more enumeration, auto-installs tools", s.recon_intensity, lvl(s.recon_intensity)); }
|
||||
}
|
||||
"/tempmail" | "/temp-email" => {
|
||||
match arg.trim() {
|
||||
"on" | "true" | "1" => { s.temp_email = true; println!(" temp-email: \x1b[32mon\x1b[0m — register flows may use the free mail.tm inbox to read a confirmation code"); }
|
||||
"off" | "false" | "0" => { s.temp_email = false; println!(" temp-email: \x1b[2moff\x1b[0m — a register step that requires email confirmation is reported as a blocker"); }
|
||||
_ => println!(" temp-email: {} — /tempmail on|off (opt-in disposable inbox for register confirmation)", if s.temp_email { "\x1b[32mon\x1b[0m" } else { "\x1b[2moff\x1b[0m" }),
|
||||
}
|
||||
}
|
||||
"/agents" => {
|
||||
if arg == "list" || arg == "ls" {
|
||||
let lib = agents::load(base);
|
||||
@@ -1028,6 +1038,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.recon_intensity = s.recon_intensity;
|
||||
cfg.temp_email = s.temp_email;
|
||||
cfg.proxy = s.proxy.clone();
|
||||
cfg.user_agent = s.user_agent.clone();
|
||||
cfg.max_agents = s.max_agents;
|
||||
@@ -1105,6 +1116,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.recon_intensity = s.recon_intensity;
|
||||
cfg.temp_email = s.temp_email;
|
||||
cfg.proxy = s.proxy.clone();
|
||||
cfg.user_agent = s.user_agent.clone();
|
||||
cfg.max_agents = s.max_agents;
|
||||
@@ -1599,9 +1611,9 @@ fn show(s: &Session) {
|
||||
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={} recon={} chain-depth={} max-agents={} idle-stop={}",
|
||||
println!(" │ opts : mcp={} offline={} votes={} recon={} chain-depth={} max-agents={} idle-stop={} temp-email={}",
|
||||
onoff(s.mcp), onoff(s.offline), s.vote_n, s.recon_intensity, s.chain_depth, s.max_agents,
|
||||
if s.idle_secs == 0 { "off".to_string() } else { format!("{}m", s.idle_secs / 60) });
|
||||
if s.idle_secs == 0 { "off".to_string() } else { format!("{}m", s.idle_secs / 60) }, onoff(s.temp_email));
|
||||
// Integrations at a glance (see /integrations for detail).
|
||||
{
|
||||
let ig = harness::integrations::Integrations::load(&proj_dir());
|
||||
@@ -1669,6 +1681,7 @@ fn help() {
|
||||
h("/votes <n>", "number of validator votes per finding");
|
||||
h("/chain <n>", "attack-chain depth (post-exploitation pivots; 0 = off)");
|
||||
h("/recon <1-4>", "recon intensity: 1 quick · 2 standard · 3 deep · 4 exhaustive (installs tools)");
|
||||
h("/tempmail on|off", "opt-in disposable inbox (mail.tm) to read a register confirmation code");
|
||||
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)");
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::rl::{severity_reward, RlState};
|
||||
use crate::types::{Finding, RunConfig};
|
||||
use crate::report;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde::Serialize;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
@@ -181,7 +181,80 @@ operator has explicitly authorized that specific action. Read-only, minimal proo
|
||||
emails, CPF/SSN, phones, cards, tokens): confirm access with the SMALLEST possible sample and REDACT it in the \
|
||||
report (e.g. show 1 masked record + a count) — never dump, store, or transmit the dataset. Prefer benign markers \
|
||||
and OOB/echo checks over any state-changing payload. When unsure whether an action is safe, don't do it — report \
|
||||
it as reachable and stop.\n\n";
|
||||
it as reachable and stop.\n\
|
||||
ACCOUNT-CREATION GUARDRAIL (hard limit): creating accounts is state-changing — do it ONLY to enable authenticated \
|
||||
testing, and create AT MOST 2 accounts for the ENTIRE engagement (1 normal user; a 2nd only when a test genuinely \
|
||||
needs two users, e.g. horizontal IDOR). NEVER loop, script, fuzz, or batch the register endpoint; do not write a \
|
||||
tool/PoC that submits it repeatedly; do not flood or stress the database with sign-ups. Each account must be a \
|
||||
single, clearly-marked benign identity (`nrsplt_<rand>@example.test`). REUSE the account you already made instead of \
|
||||
making new ones. To TEST the register endpoint itself (rate-limit, mass-assignment, enumeration, CSRF, weak policy), \
|
||||
send only a FEW controlled requests and prove the flaw from those — never a high-volume run. If a test would require \
|
||||
many registrations, report it as a LEAD and STOP rather than mass-creating accounts.\n\n";
|
||||
/// Per-run operational directive: the credential VAULT path, the account cap,
|
||||
/// finding-tagging rules, and (opt-in) disposable-email use. Injected into web
|
||||
/// engagement prompts so created accounts are logged for cleanup and findings
|
||||
/// are labelled authenticated vs unauthenticated.
|
||||
fn engagement_ops(cfg: &RunConfig) -> String {
|
||||
let vault = cfg.workdir.as_deref()
|
||||
.map(|d| format!("{}/vault.jsonl", d.trim_end_matches('/')))
|
||||
.unwrap_or_else(|| "the run directory's vault.jsonl".into());
|
||||
let temp = if cfg.temp_email {
|
||||
"DISPOSABLE EMAIL (enabled): if registration requires an email confirmation code/link, you MAY use the free \
|
||||
mail.tm API (no key) — `POST https://api.mail.tm/accounts` {address,password} to create an inbox (get a \
|
||||
valid domain from `GET https://api.mail.tm/domains`), `POST https://api.mail.tm/token` for a JWT, then poll \
|
||||
`GET https://api.mail.tm/messages` (Bearer JWT) to read the confirmation code/link. Use the mail.tm address \
|
||||
as the account email. Guerrilla Mail's API is a fallback. "
|
||||
} else {
|
||||
"DISPOSABLE EMAIL (disabled): if registration REQUIRES an email confirmation you cannot receive, stop and \
|
||||
report it as a blocker (do not attempt to bypass it). "
|
||||
};
|
||||
format!(
|
||||
"ENGAGEMENT OPS — TEST ACCOUNTS & VAULT:\n\
|
||||
- CREDENTIAL VAULT: whenever you create a test account or generate any credential, APPEND one JSON line to \
|
||||
`{vault}` (create the file if missing) of the form \
|
||||
{{\"account\":\"<email/username>\",\"secret\":\"<password>\",\"role\":\"<role>\",\"endpoint\":\"<register endpoint>\",\"how\":\"<curl|browser + the exact steps you used>\",\"auth_flow\":\"<how you logged in / got the session>\"}}. \
|
||||
This vault is the single place secrets are stored so you (and the operator) can consult them later; also \
|
||||
set the finding's `secret` field so it is captured even if the file write fails.\n\
|
||||
- CLEANUP: every account you create MUST be reported so it can be deleted afterwards — the run report lists \
|
||||
them from the vault. Do not leave undocumented accounts.\n\
|
||||
- LABEL FINDINGS: set `auth_context` to \"authenticated\" (proven while logged in with a test/given account) \
|
||||
or \"unauthenticated\" (proven with no session), and `account` to which user/role you used. In grey-box, be \
|
||||
explicit about which findings needed a login. In black-box, record in `how`/evidence exactly what you did \
|
||||
to create the user.\n\
|
||||
- {temp}\n\n"
|
||||
)
|
||||
}
|
||||
/// One credential the run generated (a created test account). Stored in the run
|
||||
/// vault so the operator can consult it and later delete the account.
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
struct VaultEntry {
|
||||
#[serde(default)] account: String,
|
||||
#[serde(default)] secret: String,
|
||||
#[serde(default)] role: String,
|
||||
#[serde(default)] endpoint: String,
|
||||
#[serde(default)] how: String,
|
||||
#[serde(default)] auth_flow: String,
|
||||
}
|
||||
|
||||
/// Read the agent-appended `vault.jsonl` (one JSON object per created account)
|
||||
/// from the run dir. Best-effort: skips malformed lines. `_findings` reserved for
|
||||
/// future correlation. Deduped by account identity.
|
||||
fn collect_vault(dir: &str, _findings: &[Finding]) -> Vec<VaultEntry> {
|
||||
let path = format!("{}/vault.jsonl", dir.trim_end_matches('/'));
|
||||
let mut out: Vec<VaultEntry> = Vec::new();
|
||||
if let Ok(txt) = std::fs::read_to_string(&path) {
|
||||
for line in txt.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() { continue; }
|
||||
if let Ok(v) = serde_json::from_str::<VaultEntry>(line) {
|
||||
if v.account.is_empty() { continue; }
|
||||
if !out.iter().any(|e| e.account == v.account) { out.push(v); }
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const VOTE_SYS: &str = "You are an adversarial security validator. Decide if the candidate finding is a REAL, reproducible, exploitable vulnerability whose EVIDENCE actually proves impact. Reject common false positives: input merely reflected but not executed; version/banner guesses with no working PoC; self-XSS; theoretical issues; an error message or stack trace mistaken for injection; missing, generic, or non-reproducible evidence; severity inflated beyond what the evidence demonstrates. Confirm only if the provided evidence (request/response) concretely proves the vulnerability. Reply with JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}. Default to rejected when uncertain.";
|
||||
/// Adversarial second pass for High/Critical findings: assume false positive
|
||||
/// until the evidence forces otherwise. A finding that can't withstand the
|
||||
@@ -214,6 +287,7 @@ const DECISION_DOCTRINE: &str = "DECIDE WHERE TO ATTACK (analyse, then act):\n\
|
||||
- Mine PARAMETERS: enumerate query/body/header/cookie params (incl. hidden ones from JS/source maps); for each, reason about what it does and test the fitting attack (IDOR, injection, path traversal, mass-assignment, open-redirect, SSRF). Add plausible params the API might accept (id, user, role, admin, debug, redirect, file, callback).\n\
|
||||
- MOCK realistic data: when a request needs valid-looking input to reach deeper logic, synthesize believable test data (emails, names, CPFs/SSNs with valid checksums, phone numbers, UUIDs, tokens, JSON bodies) so the flow proceeds — never use real PII.\n\
|
||||
- Authenticated testing: if you can authenticate (given creds/roles or a login you performed), REUSE the session and exploit the AUTHENTICATED surface — the endpoints/params only reachable while logged in are where the high-impact bugs live. Test as EACH role you have (e.g. normal user AND admin) and compare.\n\
|
||||
- Self-register when no creds are given: if the app allows sign-up, ANALYZE the register form (probe `form_details` has action/method/fields) and CREATE one clearly-marked benign test account (`nrsplt_<rand>@example.test`) — with curl (GET for CSRF+cookies, then POST the fields) or the Playwright browser for JS-rendered/multi-step forms — then log in and REUSE that session for authenticated testing. Register a second account only when a test needs two users. Non-destructive: one account, no mass-registration/spam; at signup also try mass-assignment (`role=admin`/`isAdmin`) and report it if accepted.\n\
|
||||
- Build PoCs when needed: for issues that need an artifact to prove (clickjacking → an HTML page that frames the target; CSRF → an auto-submitting HTML form; a multi-step or timing exploit → a script), WRITE the PoC to the run's PoC dir, run/validate it, and cite the file in the evidence.\n\
|
||||
- Test control BYPASSES: when something returns 401/403/redirect or is 'blocked', try to bypass it (verb tampering, path/case/encoding normalization, X-Original-URL / X-Rewrite-URL / X-Forwarded-* headers, missing-vs-invalid token, direct object/API access) and confirm the bypass with the two requests.\n\n";
|
||||
|
||||
@@ -294,6 +368,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let verbose = cfg.verbose;
|
||||
let mcp_on = pool.mcp_config.is_some();
|
||||
let directives = operator_directives(&cfg);
|
||||
let ops = engagement_ops(&cfg);
|
||||
// Token economy: each agent gets a capped recon context, not the full blob.
|
||||
let recon_ctx: String = recon.chars().take(3500).collect();
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
@@ -301,6 +376,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let target = target.clone();
|
||||
let recon = recon_ctx.clone();
|
||||
let directives = directives.clone();
|
||||
let ops = ops.clone();
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
if pool.stop_exploiting() {
|
||||
@@ -312,13 +388,16 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
let user = format!(
|
||||
"AUTHORIZED engagement — you have explicit permission to test {target}. \
|
||||
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
|
||||
{directives}{react}{depth}{decision}{safety}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}. \
|
||||
`evidence` must contain the concrete proof (request/response excerpt).",
|
||||
{directives}{react}{depth}{decision}{safety}{ops}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
|
||||
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret}}. \
|
||||
`evidence` must contain the concrete proof (request/response excerpt). \
|
||||
Set `auth_context` to \"authenticated\" or \"unauthenticated\"; set `account` to the test user/role you used (if any); \
|
||||
for a created test account set `secret` to its generated password (it is stored in the run vault and masked in the report).",
|
||||
target = target,
|
||||
directives = directives,
|
||||
react = REACT_DOCTRINE,
|
||||
depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, safety = SAFETY_DOCTRINE,
|
||||
ops = ops,
|
||||
doctrine = tool_doctrine(mcp_on),
|
||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||
);
|
||||
@@ -516,6 +595,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
||||
let verbose = cfg.verbose;
|
||||
let mcp_on = pool.mcp_config.is_some();
|
||||
let directives = operator_directives(&cfg);
|
||||
let ops = engagement_ops(&cfg);
|
||||
let recon_ctx: String = recon.chars().take(3000).collect();
|
||||
let leads_ctx = code_leads.clone();
|
||||
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
|
||||
@@ -523,6 +603,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
||||
let target = target.clone();
|
||||
let recon = recon_ctx.clone();
|
||||
let directives = directives.clone();
|
||||
let ops = ops.clone();
|
||||
let leads = leads_ctx.clone();
|
||||
let txc = tx.clone();
|
||||
async move {
|
||||
@@ -534,11 +615,12 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
|
||||
}
|
||||
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}{depth}{decision}{safety}{doctrine}{body}\n\n\
|
||||
Proceed and PROVE each issue against the LIVE app.\n\n{directives}{leads}{react}{depth}{decision}{safety}{ops}{doctrine}{body}\n\n\
|
||||
Reply ONLY a JSON array of confirmed findings (may be []): \
|
||||
{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
|
||||
{{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret}}. \
|
||||
Set `auth_context` (authenticated/unauthenticated) and `account` (test user/role used); for a created account set `secret`.",
|
||||
target = target, directives = directives, leads = leads,
|
||||
react = REACT_DOCTRINE, depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, safety = SAFETY_DOCTRINE, doctrine = tool_doctrine(mcp_on),
|
||||
react = REACT_DOCTRINE, depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, safety = SAFETY_DOCTRINE, ops = ops, doctrine = tool_doctrine(mcp_on),
|
||||
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
|
||||
);
|
||||
match pool.complete_routed(Task::Exploit, &ag.name, &ag.system, &user).await {
|
||||
@@ -935,6 +1017,53 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
|
||||
// White-box/skills are symbolic → deterministic belief; grey-box carries source too.
|
||||
let whitebox = matches!(gmode, GroundMode::Symbolic | GroundMode::Either);
|
||||
|
||||
// --- Credential vault & test-account cleanup ---------------------------
|
||||
// Consolidate every test account created this run (from the agent-appended
|
||||
// vault.jsonl AND any finding that carried a generated `secret`) into a single
|
||||
// vault.json the operator can consult, then MASK the secret in the report and
|
||||
// add a cleanup summary listing the accounts to delete.
|
||||
if let Some(dir) = cfg.workdir.clone() {
|
||||
let mut vault = collect_vault(&dir, &findings);
|
||||
// Fold in secrets captured on findings (dedup by account identity).
|
||||
for f in &findings {
|
||||
if !f.secret.is_empty() && !f.account.is_empty()
|
||||
&& !vault.iter().any(|v| v.account == f.account) {
|
||||
vault.push(VaultEntry {
|
||||
account: f.account.clone(), secret: f.secret.clone(), role: String::new(),
|
||||
endpoint: f.endpoint.clone(), how: f.payload.clone(), auth_flow: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !vault.is_empty() {
|
||||
let path = format!("{}/vault.json", dir.trim_end_matches('/'));
|
||||
if let Ok(j) = serde_json::to_string_pretty(&vault) { let _ = std::fs::write(&path, j); }
|
||||
let _ = tx.send(format!(
|
||||
"notify: 🔐 vault: {} test account(s) saved → {} — DELETE these after the engagement",
|
||||
vault.len(), path)).await;
|
||||
// Cleanup summary finding (secrets live only in the vault, masked here).
|
||||
let list = vault.iter()
|
||||
.map(|v| format!("• {}{} — created via {}", v.account,
|
||||
if v.role.is_empty() { String::new() } else { format!(" [{}]", v.role) },
|
||||
if v.how.is_empty() { "the registration flow".to_string() } else { v.how.chars().take(160).collect::<String>() }))
|
||||
.collect::<Vec<_>>().join("\n");
|
||||
findings.push(Finding {
|
||||
id: "test-accounts".into(), agent: "account_registration_and_forms".into(),
|
||||
title: "Test accounts created during the engagement (DELETE after)".into(),
|
||||
severity: "Info".into(), endpoint: cfg.target.clone(),
|
||||
evidence: format!("{} account(s) created for authenticated testing. Credentials are in vault.json (not shown here).\n{}", vault.len(), list),
|
||||
impact: "Operational cleanup: remove these accounts once testing is complete.".into(),
|
||||
remediation: "Delete the listed test accounts; rotate anything they touched.".into(),
|
||||
validated: true, confidence: 1.0, auth_context: "n/a".into(),
|
||||
account: format!("{} test account(s)", vault.len()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// Mask any generated secret so it never appears in the human report.
|
||||
for f in findings.iter_mut() {
|
||||
if !f.secret.is_empty() { f.secret = "•••• (see vault.json)".into(); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- v3.5.2 report-hygiene & exploitation-depth pass ---
|
||||
// Calibrate inflated/unproven High-Critical to Medium, flag exposures that
|
||||
// were never exploited ("exposed → exploited"), and advise consolidating
|
||||
|
||||
@@ -47,6 +47,20 @@ pub struct PathHit {
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// A parsed HTML `<form>` — enough for an agent to auto-submit it (e.g. register
|
||||
/// an account) with curl or the browser, without re-parsing the page.
|
||||
#[derive(Serialize, Default, Clone)]
|
||||
pub struct FormInfo {
|
||||
pub action: String,
|
||||
pub method: String,
|
||||
/// input/select/textarea field names with their type (name → type).
|
||||
pub fields: Vec<(String, String)>,
|
||||
/// Best-effort role guess: "register" | "login" | "search" | "other".
|
||||
pub kind: String,
|
||||
/// True if a CSRF/anti-forgery hidden token was seen in the form.
|
||||
pub has_csrf: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct Probe {
|
||||
pub url: String,
|
||||
@@ -63,6 +77,9 @@ pub struct Probe {
|
||||
pub cors: Cors,
|
||||
pub scripts: Vec<String>,
|
||||
pub forms: usize,
|
||||
/// Parsed forms (action/method/fields) so an agent can auto-submit them —
|
||||
/// e.g. register a test account to reach the authenticated surface.
|
||||
pub form_details: Vec<FormInfo>,
|
||||
pub interesting_paths: Vec<PathHit>,
|
||||
/// Baseline for a random non-existent path (status + body length), so agents
|
||||
/// can tell a real hit from a soft-404 catch-all.
|
||||
@@ -97,6 +114,66 @@ fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
|
||||
Some(&s[i..j])
|
||||
}
|
||||
|
||||
/// Read one HTML attribute value (double- or single-quoted) from a tag slice.
|
||||
fn attr(tag: &str, name: &str) -> String {
|
||||
for q in ["\"", "'"] {
|
||||
if let Some(v) = between(tag, &format!("{name}={q}"), q) {
|
||||
return v.trim().to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Best-effort parse of the HTML `<form>`s on a page so agents can auto-submit
|
||||
/// them (e.g. register a test account) without re-parsing. Non-destructive: this
|
||||
/// only READS the markup. Bounded to the first few forms.
|
||||
fn parse_forms(body: &str) -> Vec<FormInfo> {
|
||||
let mut out = Vec::new();
|
||||
for chunk in body.split("<form").skip(1).take(8) {
|
||||
// The form's own attributes live before the first '>'.
|
||||
let head = chunk.split('>').next().unwrap_or("");
|
||||
let inner = chunk.split("</form").next().unwrap_or(chunk);
|
||||
let mut f = FormInfo {
|
||||
action: attr(head, "action"),
|
||||
method: {
|
||||
let m = attr(head, "method");
|
||||
if m.is_empty() { "get".into() } else { m.to_lowercase() }
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// Fields: <input>, <select>, <textarea> — capture name + type.
|
||||
for tag in ["<input", "<select", "<textarea"] {
|
||||
for seg in inner.split(tag).skip(1) {
|
||||
let t = seg.split('>').next().unwrap_or("");
|
||||
let name = attr(t, "name");
|
||||
if name.is_empty() { continue; }
|
||||
let ty = if tag == "<input" {
|
||||
let ty = attr(t, "type");
|
||||
if ty.is_empty() { "text".into() } else { ty.to_lowercase() }
|
||||
} else { tag.trim_start_matches('<').into() };
|
||||
if ty == "hidden" && (t.to_lowercase().contains("csrf") || t.to_lowercase().contains("token") || name.to_lowercase().contains("csrf") || name.to_lowercase().contains("_token")) {
|
||||
f.has_csrf = true;
|
||||
}
|
||||
if f.fields.len() < 25 && !f.fields.iter().any(|(n, _)| *n == name) {
|
||||
f.fields.push((name, ty));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guess the form's role from action + field names.
|
||||
let hay = format!("{} {}", f.action, f.fields.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(" ")).to_lowercase();
|
||||
let has_pw = f.fields.iter().any(|(_, t)| t == "password");
|
||||
f.kind = if hay.contains("regist") || hay.contains("signup") || hay.contains("sign-up") || hay.contains("create") || (has_pw && (hay.contains("confirm") || hay.contains("repeat"))) {
|
||||
"register".into()
|
||||
} else if hay.contains("login") || hay.contains("signin") || hay.contains("sign-in") || hay.contains("auth") || has_pw {
|
||||
"login".into()
|
||||
} else if hay.contains("search") || hay.contains("query") || f.fields.iter().any(|(n, _)| n == "q") {
|
||||
"search".into()
|
||||
} else { "other".into() };
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the probe. Never panics; on total failure returns a Probe with a note.
|
||||
pub async fn probe(target: &str) -> Probe {
|
||||
let mut p = Probe { url: target.to_string(), ..Default::default() };
|
||||
@@ -148,6 +225,7 @@ pub async fn probe(target: &str) -> Probe {
|
||||
p.title = t.trim().chars().take(120).collect();
|
||||
}
|
||||
p.forms = body.matches("<form").count();
|
||||
p.form_details = parse_forms(&body);
|
||||
// linked scripts (src="...")
|
||||
for cap in body.split("<script").skip(1) {
|
||||
if let Some(src) = between(cap, "src=\"", "\"").or_else(|| between(cap, "src='", "'")) {
|
||||
@@ -220,7 +298,7 @@ pub fn probe_json(p: &Probe) -> String {
|
||||
/// One-line human summary for the live feed.
|
||||
pub fn probe_summary(p: &Probe) -> String {
|
||||
format!(
|
||||
"probe: HTTP {} {}{} · {}{} · sec-headers {}/6 · {} cookie(s) · {} script(s){}{}",
|
||||
"probe: HTTP {} {}{} · {}{} · sec-headers {}/6 · {} cookie(s) · {} script(s){}{}{}",
|
||||
p.status,
|
||||
if p.server.is_empty() { "".into() } else { format!("{} ", p.server) },
|
||||
if p.tech.is_empty() { "".to_string() } else { format!("[{}]", p.tech.join(",")) },
|
||||
@@ -229,7 +307,47 @@ pub fn probe_summary(p: &Probe) -> String {
|
||||
p.security_headers.present,
|
||||
p.cookies.len(),
|
||||
p.scripts.len(),
|
||||
{
|
||||
let kinds: Vec<&str> = p.form_details.iter().map(|f| f.kind.as_str()).filter(|k| *k == "register" || *k == "login").collect();
|
||||
if kinds.is_empty() { String::new() } else { format!(" · forms: {}", kinds.join(",")) }
|
||||
},
|
||||
if p.cors.reflects_origin { " · CORS reflects origin!" } else { "" },
|
||||
if p.interesting_paths.is_empty() { String::new() } else { format!(" · hits: {}", p.interesting_paths.iter().map(|h| h.path.clone()).collect::<Vec<_>>().join(",")) },
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_forms;
|
||||
|
||||
#[test]
|
||||
fn parses_register_form_fields_and_kind() {
|
||||
let html = r#"<html><body>
|
||||
<form action="/api/register" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="email" name="email">
|
||||
<input type="password" name="password">
|
||||
<input type="password" name="confirmPassword">
|
||||
<input type="hidden" name="csrf_token" value="abc">
|
||||
<button>Sign up</button>
|
||||
</form>
|
||||
<form action="/search" method="get"><input name="q"></form>
|
||||
</body></html>"#;
|
||||
let forms = parse_forms(html);
|
||||
assert_eq!(forms.len(), 2);
|
||||
let reg = &forms[0];
|
||||
assert_eq!(reg.action, "/api/register");
|
||||
assert_eq!(reg.method, "post");
|
||||
assert_eq!(reg.kind, "register");
|
||||
assert!(reg.has_csrf, "hidden csrf_token should be detected");
|
||||
assert!(reg.fields.iter().any(|(n, t)| n == "password" && t == "password"));
|
||||
assert_eq!(forms[1].kind, "search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_form_detected_by_password() {
|
||||
let html = r#"<form action="/login"><input name="user"><input type="password" name="pw"></form>"#;
|
||||
let f = parse_forms(html);
|
||||
assert_eq!(f[0].kind, "login");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,21 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
|
||||
format!(
|
||||
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}</h3>\
|
||||
<div class=m>{} · {} · CVSS {} · votes {} · conf {:.2}</div>\
|
||||
<div class=m>Endpoint: {}</div>\
|
||||
<div class=m>Endpoint: {}</div>{authline}\
|
||||
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>\
|
||||
<h4>Impact</h4><p>{}</p><h4>Remediation</h4><p>{}</p></section>",
|
||||
sev_color(&f.severity), esc(&f.severity), i + 1, esc(&f.title),
|
||||
esc(&f.agent), esc(&f.cwe), esc(&f.cvss), esc(&f.votes), f.confidence,
|
||||
esc(&f.endpoint), esc(&f.payload), esc(&f.evidence), esc(&f.impact), esc(&f.remediation),
|
||||
authline = {
|
||||
// Show the auth context and which test account proved this finding.
|
||||
if f.auth_context.is_empty() && f.account.is_empty() { String::new() }
|
||||
else {
|
||||
let ac = if f.auth_context.is_empty() { String::new() } else { format!("Auth: <b>{}</b>", esc(&f.auth_context)) };
|
||||
let acct = if f.account.is_empty() { String::new() } else { format!("{}Account: {}", if ac.is_empty() { "" } else { " · " }, esc(&f.account)) };
|
||||
format!("<div class=m>{ac}{acct}</div>")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -47,6 +47,20 @@ pub struct Finding {
|
||||
/// IDs of findings this one chains from (attack-path edges).
|
||||
#[serde(default)]
|
||||
pub chains_from: Vec<String>,
|
||||
/// Auth context in which this was proven: "authenticated" | "unauthenticated"
|
||||
/// | "" (unknown). Lets a report distinguish pre- and post-login findings —
|
||||
/// important in grey/black-box where the agent self-registered to test.
|
||||
#[serde(default)]
|
||||
pub auth_context: String,
|
||||
/// The test account/role used to prove this finding (e.g. "user1 · nrsplt_x@example.test"
|
||||
/// or "admin"). Empty when the finding needed no account.
|
||||
#[serde(default)]
|
||||
pub account: String,
|
||||
/// A credential generated during the run (password/token) for a created test
|
||||
/// account. Captured here transiently, moved to the run's vault, and MASKED in
|
||||
/// the human report. Only set on "test account created" capability findings.
|
||||
#[serde(default)]
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl Default for Finding {
|
||||
@@ -72,6 +86,9 @@ impl Default for Finding {
|
||||
exploitability: String::new(),
|
||||
business_impact: String::new(),
|
||||
chains_from: Vec::new(),
|
||||
auth_context: String::new(),
|
||||
account: String::new(),
|
||||
secret: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +158,11 @@ pub struct RunConfig {
|
||||
/// more recon rounds, more active enumeration, and auto-installing tools.
|
||||
#[serde(default = "default_recon")]
|
||||
pub recon_intensity: usize,
|
||||
/// Opt-in: when the app requires email confirmation to register, allow the
|
||||
/// agent to use a free disposable-inbox API (mail.tm) to read the code/link.
|
||||
/// Off by default. Account creation is still capped by the safety guardrail.
|
||||
#[serde(default)]
|
||||
pub temp_email: bool,
|
||||
}
|
||||
|
||||
fn default_vote() -> usize {
|
||||
@@ -179,6 +201,7 @@ impl RunConfig {
|
||||
proxy: None,
|
||||
user_agent: None,
|
||||
recon_intensity: 3,
|
||||
temp_email: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user