feat: human-in-loop validator (flag not delete), MD/JSON reports, SPA methodology, robust RL

- Validator no longer silently drops uncertain findings. New Finding.review_status
  (confirmed | needs-review) + review_reason. validate() keeps partial-support as
  needs-review (drops only zero-support noise); refute_pass() demotes refuted
  High/Crit to needs-review instead of deleting; grounding::gate() flags ungrounded
  as needs-review instead of retain-dropping. Reports separate the two buckets.
- Reports: report::write_all writes report.md (human) + report.json (structured
  confirmed/needs-review/all) + report.html + Typst PDF. Wired into finalize_run
  and report_raw. HTML shows a NEEDS REVIEW badge + reason.
- SPA/REST methodology: when recon detects a JS SPA and/or REST/GraphQL API,
  inject SPA_API_DOCTRINE — directions (not an answer key) for a Juice-Shop-class
  surface: map API from JS bundle, hidden client routes, SQLi login-bypass/UNION,
  JWT none/RS→HS forge, IDOR/BOLA + mass-assignment, path-traversal + poison null
  byte, forgot-password OSINT, exposed /metrics, DOM XSS, NoSQL, SSRF, redirect
  allowlist, XXE, coupon crypto. Agents still discover and prove live.
- RL reward shaping: confirmed (severity × confidence) strong, needs-review small
  positive lead, no-find slight decay — reliable agents rise in selection.
- Tests: grounding gate flag-not-delete; report md/json bucket separation.
This commit is contained in:
CyberSecurityUP
2026-07-30 20:06:05 -03:00
parent a6643968e2
commit 76121fd739
7 changed files with 272 additions and 21 deletions
+2 -2
View File
@@ -797,7 +797,7 @@ pub(crate) fn report_raw(target: &str, findings: &[harness::types::Finding], wor
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);
let _ = harness::report::write_all(target, &fs, workdir); // md + json + html + pdf
write_status(workdir, "stopped-raw", &format!("\"findings\":{}", fs.len()));
}
@@ -808,7 +808,7 @@ pub(crate) fn finalize_run(mut out: RunOutput, workdir: &Path) -> RunOutput {
if out.target.is_empty() {
out.target = workdir.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();
}
let _ = harness::report::typst_report(&out.target, &out.findings, workdir);
let _ = harness::report::write_all(&out.target, &out.findings, workdir); // md + json + html + pdf
write_status(workdir, "complete", &format!("\"findings\":{},\"agents_ran\":{}", out.findings.len(), out.agents_ran.len()));
out
}
+19 -4
View File
@@ -136,13 +136,22 @@ pub fn gate(mut findings: Vec<Finding>, context: &str, mode: GroundMode) -> (Vec
let mut demoted = 0;
for f in findings.iter_mut() {
let g = ground(f, context, mode);
if !g.ok {
if g.ok {
// Grounded + already vote-confirmed → mark confirmed for the report.
if f.validated && f.review_status.is_empty() {
f.review_status = "confirmed".into();
}
} else {
// Ungrounded: DON'T delete — demote to needs-review so a human can judge
// (an agent may have proven it without a machine-recognisable receipt).
f.validated = false;
f.review_status = "needs-review".into();
if f.review_reason.is_empty() { f.review_reason = "no machine-verifiable receipt".into(); }
f.votes = format!("{} · receipt_missing", f.votes);
demoted += 1;
}
}
findings.retain(|f| f.validated);
// Keep everything; the report separates confirmed from needs-review.
(findings, demoted)
}
@@ -206,12 +215,18 @@ mod tests {
}
#[test]
fn gate_keeps_grounded_and_demotes_prose() {
fn gate_flags_ungrounded_for_review_without_deleting() {
let good = sast_finding();
let bad = Finding { title: "vibes".into(), endpoint: "somewhere".into(),
evidence: "looks bad".into(), validated: true, ..Default::default() };
let (kept, demoted) = gate(vec![good, bad], "", GroundMode::Symbolic);
assert_eq!(kept.len(), 1);
// Nothing deleted — the ungrounded one is kept and flagged, not dropped.
assert_eq!(kept.len(), 2);
assert_eq!(demoted, 1);
let confirmed = kept.iter().find(|f| f.title.contains("SQL")).unwrap();
assert_eq!(confirmed.review_status, "confirmed");
let flagged = kept.iter().find(|f| f.title == "vibes").unwrap();
assert_eq!(flagged.review_status, "needs-review");
assert!(!flagged.validated);
}
}
+86 -11
View File
@@ -305,6 +305,49 @@ const DECISION_DOCTRINE: &str = "DECIDE WHERE TO ATTACK (analyse, then act):\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";
/// Methodology directions for a modern JS SPA backed by a REST/GraphQL API
/// (Angular/React/Vue front + Node/Express-style API — the shape of OWASP Juice
/// Shop and many real apps). These are DIRECTIONS on HOW to hunt each vuln class,
/// NOT a challenge answer key: the agent still discovers, tests and PROVES each
/// issue against the live app. Injected only when recon shows a SPA/REST surface.
const SPA_API_DOCTRINE: &str = "SPA + REST/API METHODOLOGY (modern JS app — hunt the API, not just the shell):\n\
- MAP THE API FROM THE BUNDLE: curl the main JS bundle(s) (`main*.js`, `runtime*.js`, `vendor*.js`) and grep for route \
paths and API calls — client routes (Angular/React router table), `/rest/…`, `/api/…`, GraphQL, and any `http(s)://` / \
relative endpoints, param names, and hardcoded secrets/keys/emails. Build the real endpoint list from the code, then hit it.\n\
- HIDDEN CLIENT ROUTES: SPA pages are client-side and often unlinked — extract the router table from the bundle AND brute \
common ones (`#/administration`, `#/admin`, `#/accounting`, `#/score-board`, `#/wallet`, `#/deluxe-membership`); a route that \
renders admin/score/scoreboard content is a broken-access-control finding.\n\
- AUTH & SQLi: on the login endpoint try SQLi auth bypass (`' OR 1=1--`, `admin@…'--`, tautologies) in the email/username; on \
search/query params try error-based then UNION SELECT to exfil the schema and user table (email+password hash). Also test \
weak/default admin creds and account/email ENUMERATION (different response for existing vs unknown user).\n\
- JWT: decode any JWT; test alg:none / 'unsigned' acceptance, RS256→HS256 confusion using the server's public key as the HMAC \
secret, `kid`/`jku` injection, and whether the signature is verified at all — forge a token impersonating another/admin user.\n\
- IDOR / BOLA / mass-assignment: numeric or guessable ids on `/api/<Object>/:id` (baskets, orders, feedbacks, reviews, users) \
— change the id or the owner field to read/modify another user's data; at REGISTER/PATCH add unexpected fields (`role=admin`, \
`isAdmin`, `deletedAt`, `id`) and check if the server binds them (privilege escalation / resurrecting deleted accounts).\n\
- FILE ACCESS: file/download/ftp endpoints — path traversal (`../`), and POISON NULL BYTE / double-encoding (`%2500`, `%00`) \
to defeat an extension allowlist and reach backup/config files (`*.bak`, `package.json.bak`, `*.md.bak`, `.env`, coupons/keys). \
Enumerate an open `/ftp` or static dir if present.\n\
- FORGOT-PASSWORD & OSINT: the reset flow keyed on a security question — the answer is often discoverable from the app's own \
data (profile, photo-wall image captions/EXIF, reviews). Use the app's public data to answer it, then reset.\n\
- OBSERVABILITY / EXPOSURE: probe `/metrics` (Prometheus), `/support/logs`, access logs, `/redirect?to=`, GraphQL introspection, \
Swagger/OpenAPI, and any `/rest/*` that returns more fields than the UI shows (excessive data exposure — password hashes, etc.).\n\
- CLIENT-SIDE & MISC: DOM XSS where user input is written to the DOM/innerHTML (search, product name) — prove it executes; \
NoSQL operator injection (`$ne`,`$gt`,`$where`) on review/update endpoints; SSRF on any URL-fetching field (profile image URL); \
open-redirect allowlist bypass by embedding an allowlisted substring; XXE on deprecated B2B/XML interfaces; weak/guessable \
coupon or discount codes (reverse the pattern from the bundle). Force ERROR HANDLING flaws with malformed JSON / wrong types to \
surface stack traces.\n\
Chain what you find (leaked key → forged token → admin route → data export). Prove every issue with the exact request+response.\n\n";
/// Does the recon/probe surface look like a JS SPA and/or a REST/GraphQL API,
/// so the SPA methodology is worth injecting?
fn looks_like_spa_api(recon: &str) -> bool {
let r = recon.to_lowercase();
["spa", "angular", "react", "vue", "app-root", "/rest/", "/api/", "graphql", "swagger",
"polyfills", "runtime.", "main.js", "\"scripts\""]
.iter().filter(|m| r.contains(*m)).count() >= 1
}
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
pool.set_progress(tx.clone());
@@ -404,6 +447,12 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let mcp_on = pool.mcp_config.is_some();
let directives = operator_directives(&cfg);
let ops = engagement_ops(&cfg);
// Inject the SPA/REST methodology only when the target looks like a JS SPA or
// an API — gives the agents concrete directions on a Juice-Shop-class surface.
let spa = if looks_like_spa_api(&recon) {
let _ = tx.send("recon: SPA/REST surface detected — applying API-hunting methodology".into()).await;
SPA_API_DOCTRINE
} else { "" };
// 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())
@@ -423,7 +472,7 @@ 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}{ops}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
{directives}{react}{depth}{decision}{spa}{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); \
@@ -431,7 +480,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
target = target,
directives = directives,
react = REACT_DOCTRINE,
depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, safety = SAFETY_DOCTRINE,
depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, spa = spa, safety = SAFETY_DOCTRINE,
ops = ops,
doctrine = tool_doctrine(mcp_on),
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
@@ -987,14 +1036,26 @@ async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n:
if f.confidence == 0.0 && total > 0 {
f.confidence = yes as f64 / total as f64;
}
let _ = txc.send(format!("vote {}{} ({})", f.title, if f.validated { "CONFIRMED" } else { "rejected" }, f.votes)).await;
// Human-in-the-loop triage: confirmed on quorum; kept & FLAGGED
// (not deleted) when it has partial support; only zero-support
// candidates are dropped as noise.
if f.validated {
f.review_status = "confirmed".into();
} else if yes >= 1 || total == 0 {
f.review_status = "needs-review".into();
f.review_reason = if total == 0 { "validator unavailable".into() }
else { format!("below vote quorum ({yes}/{total})") };
}
let label = if f.validated { "CONFIRMED" } else if f.review_status == "needs-review" { "needs-review" } else { "rejected" };
let _ = txc.send(format!("vote {}{} ({})", f.title, label, f.votes)).await;
f
}
})
.buffer_unordered(pool.candidates.len().max(2))
.collect()
.await;
validated.into_iter().filter(|f| f.validated).collect()
// Keep confirmed AND needs-review (human decides); drop only zero-support noise.
validated.into_iter().filter(|f| f.validated || f.review_status == "needs-review").collect()
}
/// Adversarial refutation pass: every confirmed **High/Critical** finding is
@@ -1022,7 +1083,14 @@ async fn refute_pass(findings: Vec<Finding>, pool: &ModelPool, vote_n: usize, tx
if total > 0 { f.votes = format!("{} · refute {yes}/{total}", f.votes); }
kept.push(f);
} else {
let _ = tx.send(format!("vote {} → dropped by adversarial refute ({yes}/{total})", f.title)).await;
// Refuted High/Critical: don't silently delete — DEMOTE to needs-review
// and hand it to the human loop with the reason (they make the call).
f.validated = false;
f.review_status = "needs-review".into();
f.review_reason = format!("failed adversarial refute ({yes}/{total} survived)");
f.votes = format!("{} · refute {yes}/{total}", f.votes);
let _ = tx.send(format!("vote {} → flagged needs-review (adversarial refute {yes}/{total})", f.title)).await;
kept.push(f);
}
}
kept
@@ -1130,12 +1198,19 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
// Map findings to OWASP / MITRE / kill-chain stage for the attack graph.
crate::attack_graph::enrich(&mut findings);
// RL update: reward agents that produced validated findings; gently decay idle.
let hit: std::collections::HashMap<&str, f64> = findings.iter().fold(Default::default(), |mut m, f| {
let e = m.entry(f.agent.as_str()).or_insert(0.0);
*e = (*e + severity_reward(&f.severity)).min(1.0);
m
});
// RL update (robust reward shaping): an agent's reward per run =
// + strong for each CONFIRMED finding (severity × confidence),
// + small for a NEEDS-REVIEW finding (it surfaced a real lead worth a human),
// small decay for running but surfacing nothing (keeps noise agents down).
// Rewards accumulate per agent, capped to [-1, 1], so agents that reliably land
// confirmed high-severity bugs float to the top of selection on future runs.
let mut hit: std::collections::HashMap<&str, f64> = Default::default();
for f in &findings {
let base = severity_reward(&f.severity) * f.confidence.max(0.2).min(1.0);
let r = if f.review_status == "needs-review" { 0.15 } else { base };
let e = hit.entry(f.agent.as_str()).or_insert(0.0);
*e = (*e + r).clamp(-1.0, 1.0);
}
for a in &selected {
let r = hit.get(a.name.as_str()).copied().unwrap_or(-0.05);
rl.update(&a.name, r);
+121 -2
View File
@@ -52,14 +52,18 @@ pub fn html(target: &str, findings: &[Finding]) -> String {
.enumerate()
.map(|(i, f)| {
format!(
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}</h3>\
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}{review}</h3>\
<div class=m>{} · {} · CVSS {} · votes {} · conf {:.2}</div>\
<div class=m>Endpoint: {}</div>{authline}\
<div class=m>Endpoint: {}</div>{authline}{reviewnote}\
<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),
review = if needs_review(f) { " <span class=sev style=background:#8e44ad>NEEDS REVIEW</span>" } else { "" },
reviewnote = if needs_review(f) && !f.review_reason.is_empty() {
format!("<div class=m style=color:#8e44ad>⚠ Needs human review — {}</div>", esc(&f.review_reason))
} else { String::new() },
authline = {
// Show the auth context and which test account proved this finding.
if f.auth_context.is_empty() && f.account.is_empty() { String::new() }
@@ -174,3 +178,118 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
}
Ok(typ_path)
}
/// True if a finding is flagged for human review (kept, not deleted).
fn needs_review(f: &Finding) -> bool { f.review_status == "needs-review" }
/// Render a Markdown report. Confirmed findings first, then a clearly separated
/// "Needs human review" section (uncertain findings the harness kept instead of
/// deleting, so the human makes the final call).
pub fn markdown(target: &str, findings: &[Finding]) -> String {
let sorted = sorted_findings(findings);
let confirmed: Vec<&Finding> = sorted.iter().filter(|f| !needs_review(f)).collect();
let review: Vec<&Finding> = sorted.iter().filter(|f| needs_review(f)).collect();
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
for f in &confirmed { *counts.entry(f.severity.as_str()).or_default() += 1; }
let chips = if counts.is_empty() { "none".to_string() }
else { counts.iter().map(|(s, n)| format!("{s}: {n}")).collect::<Vec<_>>().join(" · ") };
let fmt = |f: &Finding, i: usize| -> String {
let mut s = format!("### {}. [{}] {}\n\n", i + 1, f.severity, f.title);
let mut meta = vec![format!("**Agent:** {}", f.agent)];
if !f.cwe.is_empty() { meta.push(format!("**CWE:** {}", f.cwe)); }
if !f.owasp.is_empty() { meta.push(format!("**OWASP:** {}", f.owasp)); }
if !f.cvss.is_empty() { meta.push(format!("**CVSS:** {}", f.cvss)); }
if !f.votes.is_empty() { meta.push(format!("**Votes:** {}", f.votes)); }
meta.push(format!("**Confidence:** {:.2}", f.confidence));
if !f.auth_context.is_empty() { meta.push(format!("**Auth:** {}", f.auth_context)); }
if !f.account.is_empty() { meta.push(format!("**Account:** {}", f.account)); }
s.push_str(&meta.join(" · "));
s.push_str("\n\n");
if needs_review(f) && !f.review_reason.is_empty() {
s.push_str(&format!("> ⚠️ **Needs human review** — {}\n\n", f.review_reason));
}
if !f.endpoint.is_empty() { s.push_str(&format!("**Endpoint:** `{}`\n\n", f.endpoint)); }
if !f.payload.is_empty() { s.push_str(&format!("**Payload**\n```\n{}\n```\n\n", f.payload)); }
if !f.evidence.is_empty() { s.push_str(&format!("**Evidence**\n```\n{}\n```\n\n", f.evidence)); }
if !f.impact.is_empty() { s.push_str(&format!("**Impact:** {}\n\n", f.impact)); }
if !f.remediation.is_empty() { s.push_str(&format!("**Remediation:** {}\n\n", f.remediation)); }
s.push_str("---\n\n");
s
};
let mut out = String::new();
out.push_str(&format!("# NeuroSploit Report — {target}\n\n"));
out.push_str("_by Joas A Santos & Red Team Leaders · NeuroSploit v3.6.5_\n\n");
out.push_str(&format!("**Confirmed:** {} ({}) · **Needs review:** {}\n\n", confirmed.len(), chips, review.len()));
out.push_str("> Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and an adversarial refute pass. \"Needs review\" findings are uncertain — kept for a human to adjudicate, not deleted.\n\n");
out.push_str(&format!("## Confirmed findings ({})\n\n", confirmed.len()));
if confirmed.is_empty() { out.push_str("_None._\n\n"); }
else { for (i, f) in confirmed.iter().enumerate() { out.push_str(&fmt(f, i)); } }
if !review.is_empty() {
out.push_str(&format!("## Needs human review ({}) — signalled, not deleted\n\n", review.len()));
for (i, f) in review.iter().enumerate() { out.push_str(&fmt(f, i)); }
}
out
}
/// Structured JSON report: run metadata + findings split into confirmed and
/// needs-review buckets (plus the flat list). Machine-consumable.
pub fn json_report(target: &str, findings: &[Finding], run_id: &str) -> String {
let confirmed: Vec<&Finding> = findings.iter().filter(|f| !needs_review(f)).collect();
let review: Vec<&Finding> = findings.iter().filter(|f| needs_review(f)).collect();
let v = serde_json::json!({
"tool": "NeuroSploit",
"version": "3.6.5",
"target": target,
"run_id": run_id,
"summary": {
"confirmed": confirmed.len(),
"needs_review": review.len(),
"total": findings.len(),
},
"confirmed": confirmed,
"needs_review": review,
"findings": findings,
});
serde_json::to_string_pretty(&v).unwrap_or_default()
}
/// Write the full report bundle: Markdown, JSON, HTML, and the Typst/PDF.
/// Returns the primary artifact path (PDF if typst present, else the .typ).
pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let run_id = dir.file_name().and_then(|s| s.to_str()).unwrap_or("run").to_string();
std::fs::write(dir.join("report.md"), markdown(target, findings))?;
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id))?;
std::fs::write(dir.join("report.html"), html(target, findings))?;
typst_report(target, findings, dir)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Finding;
#[test]
fn markdown_separates_confirmed_and_needs_review() {
let confirmed = Finding { title: "SQLi login bypass".into(), severity: "High".into(),
endpoint: "/rest/user/login".into(), evidence: "HTTP/1.1 200".into(),
review_status: "confirmed".into(), validated: true, confidence: 0.9, ..Default::default() };
let review = Finding { title: "Maybe SSRF".into(), severity: "Medium".into(),
review_status: "needs-review".into(), review_reason: "below vote quorum (1/3)".into(),
confidence: 0.33, ..Default::default() };
let md = markdown("http://t", &[confirmed, review.clone()]);
assert!(md.contains("## Confirmed findings (1)"));
assert!(md.contains("## Needs human review (1)"));
assert!(md.contains("Needs human review") && md.contains("below vote quorum"));
let js = json_report("http://t", &[review], "run1");
let v: serde_json::Value = serde_json::from_str(&js).unwrap();
assert_eq!(v["summary"]["needs_review"], 1);
assert_eq!(v["summary"]["confirmed"], 0);
}
}
@@ -61,6 +61,15 @@ pub struct Finding {
/// the human report. Only set on "test account created" capability findings.
#[serde(default)]
pub secret: String,
/// Human-in-the-loop triage state: "confirmed" (passed vote + grounding +
/// refute), "needs-review" (uncertain — partial vote, ungrounded, or refuted:
/// KEPT and flagged for a human instead of silently dropped), or "" (unset).
#[serde(default)]
pub review_status: String,
/// Why it needs review, when `review_status == "needs-review"` (e.g.
/// "below vote quorum", "no receipt", "failed adversarial refute").
#[serde(default)]
pub review_reason: String,
}
impl Default for Finding {
@@ -89,6 +98,8 @@ impl Default for Finding {
auth_context: String::new(),
account: String::new(),
secret: String::new(),
review_status: String::new(),
review_reason: String::new(),
}
}
}