mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-25 10:52:31 +02:00
feat(harness): /only REPL command; redesign the HTML report (drop attack-chain, match Typst)
/only <agent,...> (app/src/repl.rs): the REPL had no way to pin an exact agent set the way the CLI's --only flag does - Session gained a `pinned` field, wired into RunConfig in both start_background() (the live background-run path) and the blocking run() fallback. Needed so the web console's exploitation jobs can drive a real interactive REPL session (for live input while a run streams) without losing lead-pinning, which only existed as a CLI flag until now. Also usable directly from a terminal REPL session. HTML report (crates/harness/src/report.rs, html()): rebuilt to match the Typst PDF template's design (templates/report.typ) instead of its own inconsistent styling - violet brand accent, an asset table, a 5-box executive-summary grid (all severities, zero-count included, matching Typst's grid exactly), a Vulnerability Summary table, and severity- left-bordered finding cards with a compact field grid (Criticality / Status / OWASP-CWE / Confidence / Location / Agent / Auth context) before Description-Impact / Proof of Concept / Evidence / Remediation - same field order and labels as the Typst template. Dropped the Mermaid attack-path/kill-chain section entirely (the web console's live Generative Attack Path Chaining graph covers that now, interactively). Also tidied two pre-existing formatting quirks while in there: OWASP/CWE left a dangling " · " when CWE was empty, and the confidence cell said "<votes-string> votes" even when the votes field already contained a compound descriptor like "1/1 · receipt_missing". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ecc2a9ae03
commit
07bed42467
@@ -143,7 +143,7 @@ struct LiveCheckpoint {
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help", "/onboard", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target",
|
||||
"/repo", "/auth", "/creds", "/focus", "/objective", "/scope-out", "/attach", "/context", "/mcp", "/offline",
|
||||
"/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
||||
"/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/only", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
||||
"/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations", "/quit",
|
||||
];
|
||||
|
||||
@@ -264,6 +264,10 @@ struct Session {
|
||||
color: bool,
|
||||
/// Engagement scope from onboarding: web | infra | cloud | ai | skills.
|
||||
scope: &'static str,
|
||||
/// Explicit agent allowlist (`/only <agent>[,agent2,...]`) — when non-empty,
|
||||
/// /run tests EXACTLY these agents and skips recon-based selection, same as
|
||||
/// the CLI's `--only` flag. Empty = normal recon-driven auto-selection.
|
||||
pinned: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -292,6 +296,7 @@ impl Default for Session {
|
||||
attachments: Vec::new(),
|
||||
color: true,
|
||||
scope: "web",
|
||||
pinned: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -664,6 +669,17 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {}", s.max_agents);
|
||||
}
|
||||
}
|
||||
"/only" => {
|
||||
if arg.is_empty() {
|
||||
if s.pinned.is_empty() { println!(" pinned agents: (none) — recon-driven auto-selection · set with /only <agent>[,agent2,...] · /agents list for names"); }
|
||||
else { println!(" pinned agents ({}): {} — /run tests exactly these · /only clear to unpin", s.pinned.len(), s.pinned.join(", ")); }
|
||||
} else if arg == "clear" {
|
||||
s.pinned.clear(); println!(" pinned agents cleared — back to recon-driven auto-selection");
|
||||
} else {
|
||||
s.pinned = arg.split([',', ';']).map(str::trim).filter(|x| !x.is_empty()).map(String::from).collect();
|
||||
println!(" pinned agents ({}): {} — /run tests exactly these, skipping recon-based selection", s.pinned.len(), s.pinned.join(", "));
|
||||
}
|
||||
}
|
||||
"/clear" => { print!("\x1b[2J\x1b[H"); }
|
||||
"/run" | "/go" => {
|
||||
if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) {
|
||||
@@ -1108,6 +1124,7 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
|
||||
cfg.objective = s.objective.clone();
|
||||
cfg.out_of_scope = s.out_of_scope.clone();
|
||||
cfg.auth = s.auth.clone();
|
||||
cfg.pinned = s.pinned.clone();
|
||||
// Multiple /auth identities → prepend the access-control (IDOR/BOLA/BFLA) directive.
|
||||
if let Some(rd) = roles_directive(&s.roles) {
|
||||
let base = cfg.instructions.clone().unwrap_or_default();
|
||||
@@ -1182,6 +1199,7 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
|
||||
cfg.objective = s.objective.clone();
|
||||
cfg.out_of_scope = s.out_of_scope.clone();
|
||||
cfg.auth = s.auth.clone();
|
||||
cfg.pinned = s.pinned.clone();
|
||||
if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); }
|
||||
crate::apply_creds(&mut cfg, s.creds.as_deref()).await;
|
||||
crate::subscription_preflight(&cfg).await; // warn early if the CLI isn't logged in
|
||||
@@ -1728,6 +1746,7 @@ fn help() {
|
||||
h("/sub on|off", "use local subscription login instead of an API key");
|
||||
|
||||
println!("\n \x1b[2mRUN & MONITOR\x1b[0m");
|
||||
h("/only <agent,..>", "pin exact agent(s) for /run, skipping recon-based selection (clear to unpin)");
|
||||
h("/run", "launch (runs in the BACKGROUND — keep typing)");
|
||||
h("/status [n]", "live progress + findings while running (or a past run #)");
|
||||
h("/logs [n]", "recent activity feed of the running test (recon/tools/findings)");
|
||||
|
||||
@@ -49,22 +49,44 @@ fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// Render an HTML report for the validated findings.
|
||||
/// Render an HTML report for the validated findings — same design language as
|
||||
/// the Typst PDF template (`templates/report.typ`): violet brand accent,
|
||||
/// severity-colored left-border finding cards, a 5-box executive-summary
|
||||
/// grid, and a vulnerability summary table. No attack-path/kill-chain
|
||||
/// section — that lives in the interactive web console's live graph instead.
|
||||
pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String {
|
||||
let mut sorted = findings.to_vec();
|
||||
sorted.sort_by_key(|f| sev_rank(&f.severity));
|
||||
|
||||
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
|
||||
for f in &sorted {
|
||||
*counts.entry(f.severity.as_str()).or_default() += 1;
|
||||
if !needs_review(f) { *counts.entry(f.severity.as_str()).or_default() += 1; }
|
||||
}
|
||||
let chips: String = if counts.is_empty() {
|
||||
"<span class=chip style=background:#27ae60>No validated findings</span>".into()
|
||||
// Executive-summary grid: always all 5 severities, zero-count included —
|
||||
// matches the Typst template's #grid(columns: 5, ...) exactly.
|
||||
let summary_grid: String = ["Critical", "High", "Medium", "Low", "Info"]
|
||||
.iter()
|
||||
.map(|s| format!(
|
||||
"<div class=sumbox style=border-color:{}><div class=sumn style=color:{}>{}</div><div class=suml>{}</div></div>",
|
||||
sev_color(s), sev_color(s), counts.get(*s).copied().unwrap_or(0), s.to_uppercase()
|
||||
))
|
||||
.collect();
|
||||
|
||||
// Vulnerability summary table — numbered, title, severity badge, status, OWASP.
|
||||
let vuln_summary: String = if sorted.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
counts
|
||||
.iter()
|
||||
.map(|(s, n)| format!("<span class=chip style=background:{}>{}: {}</span>", sev_color(s), s, n))
|
||||
.collect()
|
||||
let rows: String = sorted.iter().enumerate().map(|(i, f)| format!(
|
||||
"<tr><td>{}</td><td>{}</td><td><span class=sev style=background:{}>{}</span></td>\
|
||||
<td>{}</td><td>{}</td></tr>",
|
||||
i + 1, esc(&f.title), sev_color(&f.severity), esc(&f.severity),
|
||||
if needs_review(f) { "<span style=color:#8e44ad>needs-review</span>".to_string() } else { "<span style=color:#27ae60>confirmed</span>".to_string() },
|
||||
esc(&f.owasp),
|
||||
)).collect();
|
||||
format!(
|
||||
"<h2>Vulnerability Summary</h2>\
|
||||
<table class=kc><tr><th>#</th><th>Vulnerability</th><th>Severity</th><th>Status</th><th>OWASP / CWE</th></tr>{rows}</table>"
|
||||
)
|
||||
};
|
||||
|
||||
let rows: String = sorted
|
||||
@@ -72,14 +94,26 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
format!(
|
||||
"<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}{reviewnote}\
|
||||
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>{shots}\
|
||||
<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),
|
||||
"<section class=finding style=border-left-color:{sevc}>\
|
||||
<h3><span class=sev style=background:{sevc}>{sev}</span> {i}. {title}{review}</h3>\
|
||||
<table class=fieldgrid>\
|
||||
<tr><td class=fk>Criticality</td><td>{sev}</td><td class=fk>Status</td><td>{status}</td></tr>\
|
||||
<tr><td class=fk>OWASP / CWE</td><td>{owaspcwe}</td><td class=fk>Confidence</td><td>{confline}</td></tr>\
|
||||
<tr><td class=fk>Location</td><td colspan=3>{endpoint}</td></tr>\
|
||||
<tr><td class=fk>Agent</td><td>{agent}</td>{authcell}</tr>\
|
||||
</table>\
|
||||
{reviewnote}\
|
||||
<h4>Description / Impact</h4><p>{impact}</p>\
|
||||
<h4>Proof of Concept</h4><pre>{payload}</pre>\
|
||||
<h4>Evidence</h4><pre>{evidence}</pre>{shots}\
|
||||
<h4>Remediation</h4><p>{remediation}</p></section>",
|
||||
sevc = sev_color(&f.severity), sev = esc(&f.severity), i = i + 1, title = esc(&f.title),
|
||||
agent = esc(&f.agent),
|
||||
owaspcwe = [esc(&f.owasp), esc(&f.cwe)].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · "),
|
||||
confline = if f.votes.is_empty() { format!("conf {:.2}", f.confidence) } else { format!("{} · conf {:.2}", esc(&f.votes), f.confidence) },
|
||||
endpoint = esc(&f.endpoint), payload = esc(&f.payload), evidence = esc(&f.evidence),
|
||||
impact = esc(&f.impact), remediation = esc(&f.remediation),
|
||||
status = if needs_review(f) { "<span style=color:#8e44ad>needs-review</span>" } else { "<span style=color:#27ae60>confirmed</span>" },
|
||||
shots = if f.screenshots.is_empty() { String::new() } else {
|
||||
let imgs: String = f.screenshots.iter()
|
||||
.map(|p| format!("<figure class=shot><img src=\"{}\" alt=\"proof for {}\"><figcaption>{}</figcaption></figure>",
|
||||
@@ -90,13 +124,12 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
|
||||
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() }
|
||||
authcell = {
|
||||
if f.auth_context.is_empty() && f.account.is_empty() { "<td class=fk></td><td></td>".to_string() }
|
||||
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>")
|
||||
let ac = if f.auth_context.is_empty() { "—".to_string() } else { esc(&f.auth_context) };
|
||||
let acct = if f.account.is_empty() { String::new() } else { format!(" · {}", esc(&f.account)) };
|
||||
format!("<td class=fk>Auth context</td><td>{ac}{acct}</td>")
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -108,43 +141,46 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
|
||||
rows
|
||||
};
|
||||
|
||||
// Attack graph (Mermaid) + kill-chain table.
|
||||
let graph = crate::attack_graph::mermaid(&sorted);
|
||||
let graph_block = if graph.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let rows: String = sorted.iter().map(|f| format!(
|
||||
"<tr><td>{}</td><td><span class=sev style=background:{}>{}</span></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
esc(&f.stage), sev_color(&f.severity), esc(&f.severity), esc(&f.title),
|
||||
esc(&f.owasp), esc(&f.mitre), esc(&f.exploitability))).collect();
|
||||
format!(
|
||||
"<h2>Attack Path & Kill Chain</h2>\
|
||||
<div class=mermaid>{graph}</div>\
|
||||
<table class=kc><tr><th>Stage</th><th>Sev</th><th>Finding</th><th>OWASP</th><th>MITRE</th><th>Exploitability</th></tr>{rows}</table>\
|
||||
<script type=module>import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';mermaid.initialize({{startOnLoad:true,theme:'dark'}});</script>"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"<!DOCTYPE html><html><head><meta charset=utf-8><title>NeuroSploit Report — {t}</title><style>\
|
||||
table.kc{{border-collapse:collapse;width:100%;margin:14px 0;font-size:13px}}table.kc th,table.kc td{{border:1px solid #e3e3e3;padding:6px 9px;text-align:left}}\
|
||||
.mermaid{{background:#0f1117;border-radius:10px;padding:16px;margin:14px 0;overflow:auto}}\
|
||||
:root{{--violet:#7c5cff}}\
|
||||
body{{font:14px/1.6 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;max-width:860px;margin:40px auto;padding:0 24px}}\
|
||||
h1{{margin:0}}.meta{{color:#666;margin:4px 0 18px}}.chip{{color:#fff;border-radius:999px;padding:4px 12px;margin-right:8px;font-size:13px;font-weight:600}}\
|
||||
.finding{{border:1px solid #e3e3e3;border-radius:12px;padding:16px 20px;margin:16px 0}}.finding h3{{margin:0 0 8px;font-size:16px}}\
|
||||
h1{{margin:0;font-size:26px}}h2{{font-size:15px;margin:22px 0 8px}}\
|
||||
.b{{color:var(--violet);font-weight:800}}.sub{{color:#888;font-size:13px;margin:2px 0 16px}}\
|
||||
table.assettbl{{border-collapse:collapse;width:100%;margin:0 0 16px;font-size:12.5px}}\
|
||||
table.assettbl td{{border:0.5pt solid #ddd;padding:6px 9px}}table.assettbl td:first-child{{color:#888;width:160px}}\
|
||||
.summary-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin:10px 0 6px}}\
|
||||
.sumbox{{border:1px solid #ddd;border-radius:6px;padding:10px 6px;text-align:center}}\
|
||||
.sumn{{font-size:20px;font-weight:800}}.suml{{font-size:9px;letter-spacing:.4px;color:#888;margin-top:2px}}\
|
||||
table.kc{{border-collapse:collapse;width:100%;margin:8px 0 16px;font-size:12.5px}}\
|
||||
table.kc th,table.kc td{{border:0.5pt solid #ddd;padding:6px 9px;text-align:left}}\
|
||||
table.kc th{{color:#555;font-size:11px;text-transform:uppercase;letter-spacing:.3px}}\
|
||||
.finding{{border:0.5pt solid #ddd;border-left:3pt solid #999;border-radius:6px;padding:14px 18px;margin:14px 0}}\
|
||||
.finding h3{{margin:0 0 8px;font-size:15px}}\
|
||||
table.fieldgrid{{border-collapse:collapse;width:100%;font-size:11.5px;margin-bottom:6px}}\
|
||||
table.fieldgrid td{{padding:3px 6px}}.fk{{color:#888;white-space:nowrap;width:1%}}\
|
||||
.sev{{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}}.m{{color:#666;font-size:12px}}\
|
||||
pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}}\
|
||||
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
|
||||
pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px;white-space:pre-wrap}}\
|
||||
h4{{margin:12px 0 3px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--violet)}}\
|
||||
.shots{{display:flex;flex-wrap:wrap;gap:12px;margin:6px 0}}\
|
||||
.shot{{margin:0;max-width:100%}}.shot img{{max-width:100%;border:1px solid #e3e3e3;border-radius:8px;display:block}}\
|
||||
.shot{{margin:0;max-width:100%}}.shot img{{max-width:100%;border:0.5pt solid #ddd;border-radius:8px;display:block}}\
|
||||
.shot figcaption{{color:#888;font-size:11px;margin-top:3px;font-family:ui-monospace,Menlo,monospace}}\
|
||||
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
|
||||
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
|
||||
<div class=meta>Asset: <b>{asset}</b> · Target: <b>{t}</b>{techline} · v3.6.5 · multi-model validated</div>\
|
||||
<div>{chips}</div>{graph_block}<h2>Findings ({n})</h2>{body}\
|
||||
<p class=meta>Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and adversarial refute; \"needs-review\" are flagged for a human.<br>NeuroSploit v3.6.5 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
||||
t = esc(target), chips = chips, n = sorted.len(), body = body, graph_block = graph_block,
|
||||
.footer{{color:#888;font-size:11px;margin-top:24px;border-top:0.5pt solid #ddd;padding-top:10px}}\
|
||||
</style></head><body>\
|
||||
<h1><span class=b>Neuro</span>Sploit</h1><div class=sub>Penetration Test Report</div>\
|
||||
<table class=assettbl>\
|
||||
<tr><td>Asset</td><td><b>{asset}</b></td></tr>\
|
||||
<tr><td>URL / target</td><td>{t}</td></tr>\
|
||||
{techrow}{serverrow}\
|
||||
</table>\
|
||||
<h2>Executive Summary</h2><div class=summary-grid>{summary_grid}</div>\
|
||||
{vuln_summary}\
|
||||
<h2>Findings ({n})</h2>{body}\
|
||||
<p class=footer>Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and adversarial refute; \"needs-review\" are flagged for a human.<br>NeuroSploit v4.0.0 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b></p></body></html>",
|
||||
t = esc(target), n = sorted.len(), body = body, summary_grid = summary_grid, vuln_summary = vuln_summary,
|
||||
asset = esc(if meta.asset.is_empty() { "unidentified web asset" } else { &meta.asset }),
|
||||
techline = if meta.tech.is_empty() { String::new() } else { format!(" · {}", esc(&meta.tech.join(", "))) },
|
||||
techrow = if meta.tech.is_empty() { String::new() } else { format!("<tr><td>Technology</td><td>{}</td></tr>", esc(&meta.tech.join(", "))) },
|
||||
serverrow = if meta.server.is_empty() { String::new() } else { format!("<tr><td>Server</td><td>{}</td></tr>", esc(&meta.server)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user