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:
CyberSecurityUP
2026-07-30 16:20:58 -03:00
parent 51ae1edb31
commit a5cdd32a0a
9 changed files with 420 additions and 20 deletions
+16 -3
View File
@@ -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)");
+137 -8
View File
@@ -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
+119 -1
View File
@@ -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");
}
}
+10 -1
View File
@@ -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,
}
}
}