diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 7826b6f..c28f29f 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -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 [,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, } 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 [,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) { 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 ", "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)"); diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index 6f7159a..f68fb93 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -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() { - "No validated findings".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!( + "
{}
{}
", + 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!("{}: {}", sev_color(s), s, n)) - .collect() + let rows: String = sorted.iter().enumerate().map(|(i, f)| format!( + "{}{}{}\ + {}{}", + i + 1, esc(&f.title), sev_color(&f.severity), esc(&f.severity), + if needs_review(f) { "needs-review".to_string() } else { "confirmed".to_string() }, + esc(&f.owasp), + )).collect(); + format!( + "

Vulnerability Summary

\ + {rows}
#VulnerabilitySeverityStatusOWASP / CWE
" + ) }; let rows: String = sorted @@ -72,14 +94,26 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String .enumerate() .map(|(i, f)| { format!( - "

{} {}. {}{review}

\ -
{} · {} · CVSS {} · votes {} · conf {:.2}
\ -
Endpoint: {}
{authline}{reviewnote}\ -

Payload

{}

Evidence

{}
{shots}\ -

Impact

{}

Remediation

{}

", - 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), + "
\ +

{sev} {i}. {title}{review}

\ + \ + \ + \ + \ + {authcell}\ +
Criticality{sev}Status{status}
OWASP / CWE{owaspcwe}Confidence{confline}
Location{endpoint}
Agent{agent}
\ + {reviewnote}\ +

Description / Impact

{impact}

\ +

Proof of Concept

{payload}
\ +

Evidence

{evidence}
{shots}\ +

Remediation

{remediation}

", + 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::>().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) { "needs-review" } else { "confirmed" }, shots = if f.screenshots.is_empty() { String::new() } else { let imgs: String = f.screenshots.iter() .map(|p| format!("
\"proof
{}
", @@ -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!("
⚠ Needs human review — {}
", 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() { "".to_string() } else { - let ac = if f.auth_context.is_empty() { String::new() } else { format!("Auth: {}", 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!("
{ac}{acct}
") + 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!("Auth context{ac}{acct}") } }, ) @@ -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!( - "{}{}{}{}{}{}", - esc(&f.stage), sev_color(&f.severity), esc(&f.severity), esc(&f.title), - esc(&f.owasp), esc(&f.mitre), esc(&f.exploitability))).collect(); - format!( - "

Attack Path & Kill Chain

\ -
{graph}
\ - {rows}
StageSevFindingOWASPMITREExploitability
\ - " - ) - }; format!( "NeuroSploit Report — {t}\ -

NeuroSploit Penetration Test Report

\ -
Asset: {asset} · Target: {t}{techline} · v3.6.5 · multi-model validated
\ -
{chips}
{graph_block}

Findings ({n})

{body}\ -

Authorized testing only. Confirmed findings passed multi-model voting, receipt grounding and adversarial refute; \"needs-review\" are flagged for a human.
NeuroSploit v3.6.5 · by Joas A Santos & Red Team Leaders

", - 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}}\ + \ +

NeuroSploit

Penetration Test Report
\ + \ + \ + \ + {techrow}{serverrow}\ +
Asset{asset}
URL / target{t}
\ +

Executive Summary

{summary_grid}
\ + {vuln_summary}\ +

Findings ({n})

{body}\ + ", + 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!("Technology{}", esc(&meta.tech.join(", "))) }, + serverrow = if meta.server.is_empty() { String::new() } else { format!("Server{}", esc(&meta.server)) }, ) }