mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 21:19:49 +02:00
New `mobile` engagement mode: `neurosploit mobile <app.apk|app.ipa|binary>` reverse-engineers a local artifact with a dedicated `mobile` agent set, all headless and provisioned on demand (Ghidra analyzeHeadless, MobSF REST/Docker, Frida, apktool/jadx, radare2). Twelve original, generic skills (agents_md/mobile/, English): static binary triage, APK static analysis, IPA static analysis, RASP & anti-tamper mapping, root/jailbreak detection + bypass, TLS pinning detection + bypass, anti-debug detection + bypass, obfuscation analysis & deobfuscation, code-integrity / tamper-check bypass, hardcoded-secrets extraction, insecure local storage, and mobile network traffic analysis. Findings are proven from the artifact (decompilation or Frida trace), non-destructively. - agents.rs: new `mobile` Library category (loaded, counted). - pipeline.rs: run_mobile() mirroring the host pipeline with a mobile recon and headless tooling doctrine; exported from the crate. - CLI: `Cmd::Mobile` + `Mode::Mobile`, wired in main and the TUI. - README + TUTORIAL document the new test type; engagement-modes badge + table updated; "New in v4.2.0" note. Version bumped to 4.2.0 across the workspace. 383 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
928 lines
46 KiB
Rust
928 lines
46 KiB
Rust
use crate::types::Finding;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Engagement metadata for the report: names the ASSET (product + stack), not just
|
|
/// the URL. Read from `meta.json` written by the pipeline after the probe.
|
|
#[derive(Default, Clone, serde::Deserialize)]
|
|
pub struct EngagementMeta {
|
|
#[serde(default)] pub target: String,
|
|
#[serde(default)] pub asset: String,
|
|
#[serde(default)] pub title: String,
|
|
#[serde(default)] pub tech: Vec<String>,
|
|
#[serde(default)] pub server: String,
|
|
#[serde(default)] pub status: u16,
|
|
}
|
|
|
|
/// Read `<dir>/meta.json` if present (best-effort).
|
|
pub fn read_meta(dir: &Path) -> EngagementMeta {
|
|
std::fs::read_to_string(dir.join("meta.json")).ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
|
|
/// The blank, structured Typst template (rendering logic). Data (`meta`,
|
|
/// `findings`) is prepended by `typst_report` to make a self-contained file.
|
|
const TYPST_TEMPLATE: &str = include_str!("../../../templates/report.typ");
|
|
|
|
fn sev_rank(s: &str) -> u8 {
|
|
match s {
|
|
"Critical" => 0,
|
|
"High" => 1,
|
|
"Medium" => 2,
|
|
"Low" => 3,
|
|
_ => 4,
|
|
}
|
|
}
|
|
|
|
fn sev_color(s: &str) -> &'static str {
|
|
match s {
|
|
"Critical" => "#c0392b",
|
|
"High" => "#e67e22",
|
|
"Medium" => "#f1c40f",
|
|
"Low" => "#3498db",
|
|
_ => "#7f8c8d",
|
|
}
|
|
}
|
|
|
|
fn esc(s: &str) -> String {
|
|
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
|
}
|
|
|
|
/// 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 {
|
|
html_with_pocs(target, findings, meta, &[])
|
|
}
|
|
|
|
/// As [`html`], but told which scripts exist in the run's `pocs/` directory so
|
|
/// each finding can link the ones it cites.
|
|
/// Render a compliance-mapping section (one table per framework) and splice it
|
|
/// into a finished report just before `</body>`. Kept separate from
|
|
/// `html_with_pocs` so the base report has no notion of compliance and callers
|
|
/// opt in only when frameworks were requested.
|
|
pub fn with_compliance(html: String, findings: &[Finding], frameworks: &[String]) -> String {
|
|
let mut section = String::new();
|
|
for name in frameworks {
|
|
let Some(fw) = crate::compliance::Framework::parse(name) else { continue };
|
|
let r = crate::compliance::map_findings(findings, fw, true);
|
|
section.push_str(&format!(
|
|
"<h2>Compliance — {}</h2><p class=m style=\"font-style:italic\">{}</p>",
|
|
esc(&r.framework_title), esc(&r.disclaimer())
|
|
));
|
|
if r.controls_with_gaps.is_empty() {
|
|
section.push_str("<p>No confirmed finding mapped to a control in this framework. This is not evidence of compliance — only that this engagement found no gap here.</p>");
|
|
continue;
|
|
}
|
|
section.push_str("<table class=fieldgrid><tr><th>Control</th><th>Requirement</th><th>Severity</th><th>Findings</th></tr>");
|
|
for g in &r.controls_with_gaps {
|
|
section.push_str(&format!(
|
|
"<tr><td><b>{}</b></td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
|
esc(&g.control.id), esc(&g.control.requirement), esc(&g.max_severity), g.finding_ids.len()
|
|
));
|
|
}
|
|
section.push_str("</table>");
|
|
}
|
|
if section.is_empty() {
|
|
return html;
|
|
}
|
|
html.replacen("<p class=footer>", &format!("{section}<p class=footer>"), 1)
|
|
}
|
|
|
|
pub fn html_with_pocs(target: &str, findings: &[Finding], meta: &EngagementMeta, available_pocs: &[String]) -> String {
|
|
let available_pocs = available_pocs.to_vec();
|
|
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 {
|
|
if !needs_review(f) { *counts.entry(f.severity.as_str()).or_default() += 1; }
|
|
}
|
|
// 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 {
|
|
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
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, f)| {
|
|
format!(
|
|
"<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>Where the problem is</h4><p class=where>{where_}</p>\
|
|
<h4>What it means</h4><p>{impact}</p>\
|
|
<h4>How to fix it</h4><p>{remediation}</p>\
|
|
<h4>Proof of concept — step by step</h4>{steps}\
|
|
{payloadblock}\
|
|
<h4>Technical evidence</h4><pre>{evidence}</pre>{shots}\
|
|
{scripts}</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),
|
|
where_ = esc(&location_line(f)),
|
|
steps = {
|
|
// Numbered, pasteable commands. A reader who cannot
|
|
// reproduce a finding has to take it on faith, and a report
|
|
// that must be believed is worth less than one that can be
|
|
// checked.
|
|
let items: String = repro_steps(f).iter()
|
|
.map(|st| format!("<li><pre class=step>{}</pre></li>", esc(st)))
|
|
.collect();
|
|
format!("<ol class=steps>{items}</ol>")
|
|
},
|
|
payloadblock = if f.payload.trim().is_empty() { String::new() } else {
|
|
format!("<h4>Payload</h4><pre class=payload>{}</pre>", esc(f.payload.trim()))
|
|
},
|
|
scripts = {
|
|
let names = poc_scripts(f, &available_pocs);
|
|
if names.is_empty() { String::new() } else {
|
|
let items: String = names.iter()
|
|
.map(|n| format!("<li><a href=\"pocs/{n}\"><code>pocs/{n}</code></a></li>", n = esc(n)))
|
|
.collect();
|
|
format!("<h4>Runnable script (extra)</h4><p class=hint>The steps above are the proof; this script automates them.</p><ul class=pocs>{items}</ul>")
|
|
}
|
|
},
|
|
evidence = esc(&technical_evidence(f)),
|
|
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>",
|
|
esc(p), esc(&f.title), esc(p))).collect();
|
|
format!("<h4>Proof screenshots</h4><div class=shots>{imgs}</div>")
|
|
},
|
|
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() },
|
|
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() { "—".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>")
|
|
}
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
let body = if rows.is_empty() {
|
|
"<p><em>No validated findings were produced for this engagement.</em></p>".to_string()
|
|
} else {
|
|
rows
|
|
};
|
|
|
|
format!(
|
|
"<!DOCTYPE html><html><head><meta charset=utf-8><title>NeuroSploit Report — {t}</title><style>\
|
|
: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;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;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: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}}\
|
|
.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.2.0 · by <b>Joas A Santos</b> & <b>Red Team Leaders</b><br><span style=\"font-family:ui-monospace,monospace\">{provenance}</span></p></body></html>",
|
|
t = esc(target), n = sorted.len(), body = body, summary_grid = summary_grid, vuln_summary = vuln_summary,
|
|
// Which build produced this document. A report that circulates without
|
|
// it is a report nobody can trace back to the run that made it.
|
|
provenance = esc(&crate::provenance::Provenance::process().tag()),
|
|
asset = esc(if meta.asset.is_empty() { "unidentified web asset" } else { &meta.asset }),
|
|
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)) },
|
|
)
|
|
}
|
|
|
|
// ===== Typst report =====
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Proof of concept & technical evidence
|
|
//
|
|
// A finding is only useful if the reader can (a) find the problem, (b) see why
|
|
// it matters, (c) fix it, and (d) reproduce it without trusting us. The report
|
|
// used to print a payload blob and an evidence blob, which serves (d) badly and
|
|
// the rest not at all: "payload: ' OR 1=1--" tells a developer nothing about
|
|
// WHERE to look, and a PoC script attached as a file is a black box unless you
|
|
// run it.
|
|
//
|
|
// So the PoC is rendered as steps a person can paste, with the script offered
|
|
// as an extra artifact rather than as the proof itself.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Where the problem is, in one line, as precisely as the finding allows.
|
|
pub fn location_line(f: &Finding) -> String {
|
|
match (f.location.trim(), f.endpoint.trim()) {
|
|
("", "") => "(location not recorded)".into(),
|
|
("", ep) => ep.to_string(),
|
|
(loc, "") => loc.to_string(),
|
|
(loc, ep) if loc.contains(ep) => loc.to_string(),
|
|
(loc, ep) => format!("{ep} — {loc}"),
|
|
}
|
|
}
|
|
|
|
/// A curl command that reproduces the request, built from the structured
|
|
/// evidence when the agent recorded it and from the endpoint/payload otherwise.
|
|
pub fn curl_command(f: &Finding) -> String {
|
|
if let Some(ev) = &f.evidence_data {
|
|
if let Some(a) = &ev.attack {
|
|
let mut cmd = String::from("curl -i -s");
|
|
let method = a.method.to_uppercase();
|
|
if !method.is_empty() && method != "GET" {
|
|
cmd.push_str(&format!(" -X {method}"));
|
|
}
|
|
for (k, v) in &a.request_headers {
|
|
// Never print a real credential into a document that gets
|
|
// shared; the reader substitutes their own.
|
|
let val = if is_secret_header(k) { "<redacted — use your own>" } else { v.as_str() };
|
|
cmd.push_str(&format!(" \\\n -H '{k}: {val}'"));
|
|
}
|
|
if !f.payload.trim().is_empty() && method != "GET" {
|
|
cmd.push_str(&format!(" \\\n --data-raw '{}'", f.payload.replace('\'', "'\\''")));
|
|
}
|
|
cmd.push_str(&format!(" \\\n '{}'", a.url));
|
|
return cmd;
|
|
}
|
|
}
|
|
if f.endpoint.trim().is_empty() {
|
|
return String::new();
|
|
}
|
|
format!("curl -i -s '{}'", f.endpoint.trim())
|
|
}
|
|
|
|
fn is_secret_header(k: &str) -> bool {
|
|
let k = k.to_lowercase();
|
|
k == "authorization" || k == "cookie" || k == "x-api-key" || k.contains("token") || k.contains("secret")
|
|
}
|
|
|
|
/// Ordered reproduction steps. Uses what the agent recorded; falls back to a
|
|
/// minimal derived sequence so every finding carries something runnable.
|
|
pub fn repro_steps(f: &Finding) -> Vec<String> {
|
|
if !f.repro_steps.is_empty() {
|
|
return f.repro_steps.clone();
|
|
}
|
|
let mut steps = Vec::new();
|
|
let curl = curl_command(f);
|
|
if let Some(ev) = &f.evidence_data {
|
|
if let Some(b) = &ev.baseline {
|
|
steps.push(format!("Baseline — request the same resource without the payload:\ncurl -i -s '{}'", b.url));
|
|
}
|
|
if ev.identity_a.is_some() && ev.identity_b.is_some() {
|
|
let a = ev.identity_a.as_ref().unwrap();
|
|
let b = ev.identity_b.as_ref().unwrap();
|
|
steps.push(format!("As {}: curl -i -s '{}'", if a.identity.is_empty() { "the owner" } else { &a.identity }, a.url));
|
|
steps.push(format!("As {}: request the SAME resource:\ncurl -i -s '{}'", if b.identity.is_empty() { "the other identity" } else { &b.identity }, b.url));
|
|
steps.push("Compare the two bodies — the second returning the first's data is the finding.".into());
|
|
return steps;
|
|
}
|
|
}
|
|
if !curl.is_empty() {
|
|
steps.push(format!("Send the request carrying the payload:\n{curl}"));
|
|
}
|
|
if !f.payload.trim().is_empty() {
|
|
steps.push(format!("Payload used:\n{}", f.payload.trim()));
|
|
}
|
|
if steps.is_empty() {
|
|
steps.push("No reproduction steps were recorded for this finding.".into());
|
|
}
|
|
steps
|
|
}
|
|
|
|
/// The detailed technical evidence: the measured difference between baseline
|
|
/// and attack, then the raw exchanges. This is what turns "it returned a 500"
|
|
/// into something a reviewer can check.
|
|
pub fn technical_evidence(f: &Finding) -> String {
|
|
let mut out = String::new();
|
|
if let Some(ev) = &f.evidence_data {
|
|
if let (Some(b), Some(a)) = (&ev.baseline, &ev.attack) {
|
|
let d = crate::validation::diff(b, a);
|
|
out.push_str(&format!(
|
|
"MEASURED DIFFERENCE\n baseline : {} {} · {} bytes · {} ms\n attack : {} {} · {} bytes · {} ms\n delta : {}\n",
|
|
b.status, b.url, b.len(), b.elapsed_ms,
|
|
a.status, a.url, a.len(), a.elapsed_ms,
|
|
d.describe()
|
|
));
|
|
if !ev.repeats.is_empty() {
|
|
let (ok, hits) = crate::validation::reproducible(b, &ev.repeats, 2);
|
|
out.push_str(&format!(
|
|
" repeats : {hits}/{} reproduced the same difference{}\n",
|
|
ev.repeats.len(),
|
|
if ok { "" } else { " — NOT deterministic" }
|
|
));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
if !ev.marker.is_empty() {
|
|
out.push_str(&format!(
|
|
"CONTROLLED MARKER\n {} — observed: {}{}{}\n\n",
|
|
ev.marker,
|
|
if ev.marker_observed { "yes" } else { "no" },
|
|
if ev.browser_executed { " · executed in a real browser" } else { "" },
|
|
if ev.callback_received { " · out-of-band callback received" } else { "" },
|
|
));
|
|
}
|
|
for (label, x) in [("BASELINE", &ev.baseline), ("ATTACK", &ev.attack), ("AS OWNER", &ev.identity_a), ("AS OTHER IDENTITY", &ev.identity_b)] {
|
|
if let Some(x) = x {
|
|
out.push_str(&render_exchange(label, x));
|
|
}
|
|
}
|
|
}
|
|
if !f.evidence.trim().is_empty() {
|
|
out.push_str("AGENT-RECORDED EVIDENCE\n");
|
|
out.push_str(f.evidence.trim());
|
|
out.push('\n');
|
|
}
|
|
out.trim_end().to_string()
|
|
}
|
|
|
|
fn render_exchange(label: &str, x: &crate::validation::Exchange) -> String {
|
|
let mut s = format!("{label}\n {} {} → {}\n", if x.method.is_empty() { "GET" } else { &x.method }, x.url, x.status);
|
|
if !x.identity.is_empty() {
|
|
s.push_str(&format!(" identity: {}\n", x.identity));
|
|
}
|
|
for k in ["location", "set-cookie", "content-type", "access-control-allow-origin", "access-control-allow-credentials", "x-frame-options", "content-security-policy", "retry-after"] {
|
|
let v = x.header(k);
|
|
if !v.is_empty() {
|
|
s.push_str(&format!(" {k}: {}\n", clip(v, 200)));
|
|
}
|
|
}
|
|
if !x.body.trim().is_empty() {
|
|
s.push_str(&format!(" body ({} bytes, excerpt):\n{}\n", x.len(), indent(&clip(x.body.trim(), 1200), " ")));
|
|
}
|
|
s.push('\n');
|
|
s
|
|
}
|
|
|
|
fn clip(s: &str, n: usize) -> String {
|
|
if s.chars().count() <= n {
|
|
return s.to_string();
|
|
}
|
|
let cut: String = s.chars().take(n).collect();
|
|
format!("{cut}…")
|
|
}
|
|
|
|
fn indent(s: &str, pad: &str) -> String {
|
|
s.lines().map(|l| format!("{pad}{l}")).collect::<Vec<_>>().join("\n")
|
|
}
|
|
|
|
/// PoC scripts this finding cites, or that the run wrote. The script is an
|
|
/// extra artifact — the steps above are the proof.
|
|
pub fn poc_scripts(f: &Finding, available: &[String]) -> Vec<String> {
|
|
let cited = format!("{} {} {}", f.evidence, f.payload, f.repro_steps.join(" "));
|
|
let matches: Vec<String> = available.iter().filter(|p| cited.contains(p.as_str())).cloned().collect();
|
|
matches
|
|
}
|
|
|
|
/// Insert zero-width break opportunities into text that would otherwise
|
|
/// overflow the page.
|
|
///
|
|
/// Typst's `raw` does not wrap, so a 300-character URL-encoded POST body ran
|
|
/// off the page edge. The first attempt at a fix was worse: it inserted breaks
|
|
/// every N characters regardless of context, which chopped ordinary prose
|
|
/// mid-word — "rota ted", "lockoutOnFailu re=false". Evidence text is usually
|
|
/// prose with a few long machine tokens embedded in it.
|
|
///
|
|
/// So the rule is per token: anything that fits on a line is left exactly as
|
|
/// it is, and only a token too long to fit gets internal break points. A
|
|
/// zero-width space carries no width and no content, so copying the text back
|
|
/// out yields the original either way.
|
|
pub fn wrappable(s: &str) -> String {
|
|
const ZWSP: char = '\u{200b}';
|
|
// Roughly the character budget of one line in the report's 7.5pt mono at
|
|
// the page width. Prose words never reach it; encoded payloads always do.
|
|
const LONG: usize = 46;
|
|
const AFTER: &[char] = &['&', '?', '/', '=', ';', ',', '+', '%', '|'];
|
|
|
|
let mut out = String::with_capacity(s.len() + s.len() / 16);
|
|
for chunk in s.split_inclusive(char::is_whitespace) {
|
|
let (token, trailing) = match chunk.find(char::is_whitespace) {
|
|
Some(i) => (&chunk[..i], &chunk[i..]),
|
|
None => (chunk, ""),
|
|
};
|
|
if token.chars().count() <= LONG {
|
|
out.push_str(token);
|
|
} else {
|
|
let mut run = 0usize;
|
|
for ch in token.chars() {
|
|
out.push(ch);
|
|
run += 1;
|
|
if AFTER.contains(&ch) {
|
|
out.push(ZWSP);
|
|
run = 0;
|
|
} else if run >= LONG - 6 {
|
|
// A base64 blob or a hash has no separators at all; break it
|
|
// rather than let it push the margin.
|
|
out.push(ZWSP);
|
|
run = 0;
|
|
}
|
|
}
|
|
}
|
|
out.push_str(trailing);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Is the `typst` binary available on PATH?
|
|
fn typst_available() -> bool {
|
|
std::env::var_os("PATH")
|
|
.map(|p| std::env::split_paths(&p).any(|d| d.join("typst").is_file()))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn sorted_findings(findings: &[Finding]) -> Vec<Finding> {
|
|
let mut v = findings.to_vec();
|
|
v.sort_by_key(|f| sev_rank(&f.severity));
|
|
v
|
|
}
|
|
|
|
/// Escape a string for embedding inside a Typst `"..."` literal (single line).
|
|
fn tq(s: &str) -> String {
|
|
let cleaned: String = s.replace('\\', "\\\\").replace('"', "\\\"").replace(['\n', '\r'], " ");
|
|
format!("\"{}\"", cleaned)
|
|
}
|
|
|
|
/// Generate a self-contained `report.typ` (data + bundled template) in `dir`
|
|
/// and compile it to `report.pdf` via the `typst` binary. Falls back to leaving
|
|
/// the `.typ` when `typst` is unavailable.
|
|
pub fn typst_report(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();
|
|
let meta = read_meta(dir);
|
|
|
|
// Prose blocks + account list rendered in Rust, passed as strings to Typst.
|
|
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 asset = if meta.asset.is_empty() { "unidentified web asset".to_string() } else { meta.asset.clone() };
|
|
let accounts = findings.iter().find(|f| f.id == "test-accounts").map(|f| f.evidence.clone()).unwrap_or_default();
|
|
|
|
let mut data = String::new();
|
|
data.push_str(&format!(
|
|
"#let meta = (target: {}, asset: {}, tech: {}, server: {}, run_id: {}, generated: {}, model: {}, exec: {}, conclusion: {}, accounts: {}, provenance: {})\n",
|
|
tq(target), tq(&asset), tq(&meta.tech.join(", ")), tq(&meta.server),
|
|
tq(&run_id), tq("July 2026"), tq("multi-model"),
|
|
tq(&strip_md(&exec_summary(target, &meta, &confirmed, &review))),
|
|
tq(&strip_md(&conclusion(target, &meta, &confirmed, &review))),
|
|
tq(&strip_md(&accounts)),
|
|
tq(&crate::provenance::Provenance::process().tag()),
|
|
));
|
|
data.push_str("#let findings = (\n");
|
|
for f in &sorted {
|
|
let owasp = if f.owasp.is_empty() { f.cwe.clone() } else { f.owasp.clone() };
|
|
let status = if needs_review(f) { "needs-review" } else { "confirmed" };
|
|
let shots = format!("({})",
|
|
f.screenshots.iter().map(|p| format!("{},", tq(p))).collect::<String>());
|
|
// Steps go as an ARRAY so the template can render a real numbered
|
|
// list. Flattening them into one string is what produced the run-on
|
|
// paragraph in the last report, where five separate commands ran
|
|
// together as prose.
|
|
let steps = format!("({})",
|
|
repro_steps(f).iter().map(|st| format!("{},", tq(&wrappable(st)))).collect::<String>());
|
|
data.push_str(&format!(
|
|
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}, location: {}, steps: {}),\n",
|
|
tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&owasp), tq(&f.cvss),
|
|
tq(&f.endpoint), tq(&wrappable(&f.payload)), tq(&wrappable(&technical_evidence(f))), tq(&f.impact),
|
|
tq(&f.remediation), tq(&f.votes), f.confidence, tq(status),
|
|
tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }),
|
|
shots,
|
|
tq(&location_line(f)),
|
|
steps,
|
|
));
|
|
}
|
|
data.push_str(")\n\n");
|
|
|
|
let typ_path = dir.join("report.typ");
|
|
std::fs::write(&typ_path, format!("{data}{TYPST_TEMPLATE}"))?;
|
|
|
|
if typst_available() {
|
|
let pdf_path = dir.join("report.pdf");
|
|
match std::process::Command::new("typst")
|
|
.arg("compile").arg(&typ_path).arg(&pdf_path).output()
|
|
{
|
|
Ok(o) if o.status.success() && pdf_path.exists() => return Ok(pdf_path),
|
|
Ok(o) => eprintln!("typst compile failed: {}",
|
|
String::from_utf8_lossy(&o.stderr).lines().next().unwrap_or("").trim()),
|
|
Err(e) => eprintln!("typst not runnable: {e}"),
|
|
}
|
|
}
|
|
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" }
|
|
|
|
/// Strip Markdown emphasis/backticks so prose renders cleanly inside Typst.
|
|
fn strip_md(s: &str) -> String {
|
|
s.replace("**", "").replace(['`', '*'], "")
|
|
}
|
|
|
|
/// Written prose executive summary: names the asset, the counts, and the top risks.
|
|
fn exec_summary(target: &str, meta: &EngagementMeta, confirmed: &[&Finding], review: &[&Finding]) -> String {
|
|
let asset = if meta.asset.is_empty() { format!("the web asset at `{target}`") }
|
|
else { format!("**{}** (`{target}`)", meta.asset) };
|
|
let stack = if meta.tech.is_empty() { String::new() }
|
|
else { format!(" The asset fingerprints as {}.", meta.tech.join(", ")) };
|
|
if confirmed.is_empty() && review.is_empty() {
|
|
return format!("This authorized engagement assessed {asset}.{stack} No findings were \
|
|
produced: candidate issues were either unproven or rejected by multi-model adversarial \
|
|
validation. The asset presented no confirmed weaknesses within the tested scope.\n\n");
|
|
}
|
|
let by_sev = |list: &[&Finding], s: &str| list.iter().filter(|f| f.severity == s).count();
|
|
let crit = by_sev(confirmed, "Critical");
|
|
let high = by_sev(confirmed, "High");
|
|
let med = by_sev(confirmed, "Medium");
|
|
let low = by_sev(confirmed, "Low");
|
|
let mut risk = Vec::new();
|
|
if crit > 0 { risk.push(format!("{crit} critical")); }
|
|
if high > 0 { risk.push(format!("{high} high")); }
|
|
if med > 0 { risk.push(format!("{med} medium")); }
|
|
if low > 0 { risk.push(format!("{low} low")); }
|
|
let riskline = if risk.is_empty() { "no severity-rated confirmed".into() } else { risk.join(", ") };
|
|
let top: Vec<String> = confirmed.iter().take(3).map(|f| format!("*{}*", f.title)).collect();
|
|
let topline = if top.is_empty() { String::new() } else { format!(" The most significant confirmed issues are {}.", top.join(", ")) };
|
|
let reviewline = if review.is_empty() { String::new() }
|
|
else { format!(" A further **{}** finding(s) are flagged **needs-review** — kept for a human analyst to adjudicate rather than discarded.", review.len()) };
|
|
format!("This authorized penetration test assessed {asset}.{stack} The engagement confirmed \
|
|
**{} finding(s)** ({riskline}) via multi-model voting, tool-receipt grounding and an adversarial \
|
|
refute pass.{topline}{reviewline} Details, evidence and remediation follow.\n\n", confirmed.len())
|
|
}
|
|
|
|
/// Render a Markdown report: asset identification, executive summary, a
|
|
/// vulnerability table, created test accounts (from the vault), detailed
|
|
/// confirmed findings, a separate needs-review section, and a written conclusion.
|
|
pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> 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 fmt = |f: &Finding, i: usize| -> String {
|
|
let mut s = format!("### {}. [{}] {}\n\n", i + 1, f.severity, f.title);
|
|
let mut m = vec![format!("**Agent:** {}", f.agent)];
|
|
if !f.cwe.is_empty() { m.push(format!("**CWE:** {}", f.cwe)); }
|
|
if !f.owasp.is_empty() { m.push(format!("**OWASP:** {}", f.owasp)); }
|
|
if !f.cvss.is_empty() { m.push(format!("**CVSS:** {}", f.cvss)); }
|
|
if !f.votes.is_empty() { m.push(format!("**Votes:** {}", f.votes)); }
|
|
m.push(format!("**Confidence:** {:.2}", f.confidence));
|
|
if !f.auth_context.is_empty() { m.push(format!("**Auth:** {}", f.auth_context)); }
|
|
if !f.account.is_empty() { m.push(format!("**Account:** {}", f.account)); }
|
|
s.push_str(&m.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));
|
|
}
|
|
// Order follows how a reader works through a finding: where it is, what
|
|
// it means, how to fix it, then how to see it for themselves.
|
|
s.push_str(&format!("**Where the problem is:** {}\n\n", location_line(f)));
|
|
if !f.impact.is_empty() { s.push_str(&format!("**What it means:** {}\n\n", f.impact)); }
|
|
if !f.remediation.is_empty() { s.push_str(&format!("**How to fix it:** {}\n\n", f.remediation)); }
|
|
let steps = repro_steps(f);
|
|
if !steps.is_empty() {
|
|
s.push_str("**Proof of concept — step by step**\n\n");
|
|
for (n, st) in steps.iter().enumerate() {
|
|
s.push_str(&format!("{}. ```\n{}\n```\n", n + 1, st));
|
|
}
|
|
s.push('\n');
|
|
}
|
|
if !f.payload.trim().is_empty() { s.push_str(&format!("**Payload**\n```\n{}\n```\n\n", f.payload.trim())); }
|
|
let tech = technical_evidence(f);
|
|
if !tech.is_empty() { s.push_str(&format!("**Technical evidence**\n```\n{}\n```\n\n", tech)); }
|
|
if !f.screenshots.is_empty() {
|
|
s.push_str("**Proof screenshots**\n\n");
|
|
for p in &f.screenshots { s.push_str(&format!("\n\n", f.title.replace(']', ")"), p)); }
|
|
}
|
|
s.push_str("---\n\n");
|
|
s
|
|
};
|
|
|
|
let mut out = String::new();
|
|
out.push_str("# NeuroSploit Penetration Test Report\n\n");
|
|
out.push_str("_by Joas A Santos & Red Team Leaders · NeuroSploit v3.6.5 · confidential_\n\n");
|
|
|
|
// --- Asset under test ---
|
|
out.push_str("## Asset under test\n\n");
|
|
out.push_str(&format!("- **Asset:** {}\n", if meta.asset.is_empty() { "unidentified web asset".into() } else { meta.asset.clone() }));
|
|
out.push_str(&format!("- **URL / target:** `{target}`\n"));
|
|
if !meta.title.is_empty() { out.push_str(&format!("- **Page title:** {}\n", meta.title)); }
|
|
if !meta.tech.is_empty() { out.push_str(&format!("- **Technology:** {}\n", meta.tech.join(", "))); }
|
|
if !meta.server.is_empty() { out.push_str(&format!("- **Server:** {}\n", meta.server)); }
|
|
out.push('\n');
|
|
|
|
// --- Executive summary ---
|
|
out.push_str("## Executive summary\n\n");
|
|
out.push_str(&exec_summary(target, meta, &confirmed, &review));
|
|
|
|
// --- Vulnerability table ---
|
|
out.push_str("## Vulnerability summary\n\n");
|
|
if confirmed.is_empty() && review.is_empty() {
|
|
out.push_str("_No findings._\n\n");
|
|
} else {
|
|
out.push_str("| # | Vulnerability | Severity | CWE / OWASP | Status | Auth |\n");
|
|
out.push_str("|---|---------------|----------|-------------|--------|------|\n");
|
|
for (i, f) in sorted.iter().enumerate() {
|
|
let owc = if !f.owasp.is_empty() { f.owasp.clone() } else { f.cwe.clone() };
|
|
let status = if needs_review(f) { "needs-review" } else { "confirmed" };
|
|
let auth = if f.auth_context.is_empty() { "-" } else { f.auth_context.as_str() };
|
|
out.push_str(&format!("| {} | {} | {} | {} | {} | {} |\n",
|
|
i + 1, f.title.replace('|', "\\|"), f.severity, owc.replace('|', "\\|"), status, auth));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
|
|
// --- Test accounts created (from the vault cleanup finding) ---
|
|
if let Some(acc) = findings.iter().find(|f| f.id == "test-accounts") {
|
|
out.push_str("## Test accounts created (delete after)\n\n");
|
|
out.push_str("These accounts were created to reach the authenticated surface. Credentials are in the run vault (`.neurosploit/vault/<run-id>.json`); delete them once testing is complete.\n\n");
|
|
out.push_str(&format!("{}\n\n", acc.evidence));
|
|
}
|
|
|
|
// --- Detailed confirmed findings ---
|
|
out.push_str(&format!("## Confirmed findings ({})\n\n", confirmed.len()));
|
|
if confirmed.is_empty() { out.push_str("_None confirmed._\n\n"); }
|
|
else { for (i, f) in confirmed.iter().enumerate() { out.push_str(&fmt(f, i)); } }
|
|
|
|
// --- Needs-review ---
|
|
if !review.is_empty() {
|
|
out.push_str(&format!("## Needs human review ({}) — signalled, not deleted\n\n", review.len()));
|
|
out.push_str("The harness kept these uncertain findings for a human to adjudicate instead of discarding them.\n\n");
|
|
for (i, f) in review.iter().enumerate() { out.push_str(&fmt(f, i)); }
|
|
}
|
|
|
|
// --- Conclusion ---
|
|
out.push_str("## Conclusion\n\n");
|
|
out.push_str(&conclusion(target, meta, &confirmed, &review));
|
|
out
|
|
}
|
|
|
|
/// Written conclusion paragraph.
|
|
fn conclusion(target: &str, meta: &EngagementMeta, confirmed: &[&Finding], review: &[&Finding]) -> String {
|
|
let asset = if meta.asset.is_empty() { format!("the asset at `{target}`") } else { format!("**{}**", meta.asset) };
|
|
let has_high = confirmed.iter().any(|f| f.severity == "Critical" || f.severity == "High");
|
|
let mut s = String::new();
|
|
if confirmed.is_empty() && review.is_empty() {
|
|
s.push_str(&format!("Within the tested scope, {asset} did not yield confirmed vulnerabilities. \
|
|
This is not proof of absence — re-test after changes and widen scope (authenticated flows, \
|
|
business logic, and any endpoints not reachable during this run).\n"));
|
|
} else {
|
|
s.push_str(&format!("The assessment of {asset} confirmed {} finding(s)", confirmed.len()));
|
|
if has_high { s.push_str(" including high-impact issues that warrant prompt remediation"); }
|
|
s.push_str(". Prioritise fixes by severity, then re-test to verify closure.");
|
|
if !review.is_empty() {
|
|
s.push_str(&format!(" {} additional finding(s) are flagged for human review — a security \
|
|
analyst should adjudicate these before they are accepted or dismissed.", review.len()));
|
|
}
|
|
s.push_str(" Remediation guidance accompanies each finding above.\n");
|
|
}
|
|
s
|
|
}
|
|
|
|
/// 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, meta: &EngagementMeta) -> 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,
|
|
"asset": {
|
|
"name": if meta.asset.is_empty() { "unidentified web asset" } else { &meta.asset },
|
|
"title": meta.title,
|
|
"tech": meta.tech,
|
|
"server": meta.server,
|
|
},
|
|
"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).
|
|
/// A "## Reproduction — PoC scripts" section listing the runnable proof-of-concept
|
|
/// scripts agents wrote to `<run>/pocs/`. Each is a self-contained artifact the
|
|
/// operator can re-run to replicate a finding, so the report ships with a live
|
|
/// reproduction kit — not just prose. Empty string when no PoCs were produced.
|
|
pub fn pocs_section(dir: &Path) -> String {
|
|
let pocs = dir.join("pocs");
|
|
let mut entries: Vec<(String, String)> = Vec::new();
|
|
if let Ok(rd) = std::fs::read_dir(&pocs) {
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if !p.is_file() { continue; }
|
|
let name = match p.file_name().and_then(|s| s.to_str()) { Some(n) => n.to_string(), None => continue };
|
|
// First non-empty comment line doubles as a one-line description.
|
|
let desc = std::fs::read_to_string(&p).ok()
|
|
.and_then(|t| t.lines()
|
|
.map(|l| l.trim())
|
|
.find(|l| l.starts_with('#') || l.starts_with("//") || l.starts_with("/*"))
|
|
.map(|l| l.trim_start_matches(['#', '/', '*', ' ']).trim().to_string()))
|
|
.unwrap_or_default();
|
|
entries.push((name, desc));
|
|
}
|
|
}
|
|
if entries.is_empty() { return String::new(); }
|
|
entries.sort();
|
|
let mut s = String::from("## Reproduction — PoC scripts\n\n");
|
|
s.push_str("Runnable proofs written to `pocs/` during the engagement. Re-run any of \
|
|
them to replicate the corresponding finding.\n\n");
|
|
for (name, desc) in entries {
|
|
if desc.is_empty() {
|
|
s.push_str(&format!("- `pocs/{name}`\n"));
|
|
} else {
|
|
s.push_str(&format!("- `pocs/{name}` — {desc}\n"));
|
|
}
|
|
}
|
|
s.push('\n');
|
|
s
|
|
}
|
|
|
|
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();
|
|
let meta = read_meta(dir);
|
|
let mut md = markdown(target, findings, &meta);
|
|
md.push_str(&pocs_section(dir));
|
|
std::fs::write(dir.join("report.md"), md)?;
|
|
std::fs::write(dir.join("report.json"), json_report(target, findings, &run_id, &meta))?;
|
|
let pocs: Vec<String> = std::fs::read_dir(dir.join("pocs"))
|
|
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect())
|
|
.unwrap_or_default();
|
|
std::fs::write(dir.join("report.html"), html_with_pocs(target, findings, &meta, &pocs))?;
|
|
typst_report(target, findings, dir)
|
|
}
|
|
|
|
/// Rebuild every report artifact for a finished run, reading its own
|
|
/// `findings.json`.
|
|
///
|
|
/// The web console needs this: a PDF is only produced at run time, and when
|
|
/// `typst` was missing then (or the report template improved since), the
|
|
/// operator has no way to get one without re-running the engagement. This
|
|
/// regenerates from the evidence already on disk.
|
|
pub fn rebuild(dir: &Path) -> std::io::Result<PathBuf> {
|
|
let mut findings: Vec<Finding> = std::fs::read_to_string(dir.join("findings.json"))
|
|
.ok()
|
|
.and_then(|t| serde_json::from_str(&t).ok())
|
|
.unwrap_or_default();
|
|
// Re-enrich on rebuild: a run finished before a mapping existed (CVSS, a
|
|
// new CWE→technique entry) would otherwise keep reprinting the gap forever,
|
|
// and the whole point of rebuilding is to get the current report.
|
|
crate::attack_graph::enrich(&mut findings);
|
|
// A rebuild applies the CURRENT mappings, including ones that did not exist
|
|
// when the run finished.
|
|
crate::attack_graph::remap_stages(&mut findings);
|
|
crate::chain::repair(&mut findings);
|
|
crate::chain::apply_links(&mut findings);
|
|
let _ = std::fs::write(dir.join("findings.json"), serde_json::to_string_pretty(&findings).unwrap_or_default());
|
|
let status: serde_json::Value = std::fs::read_to_string(dir.join("status.json"))
|
|
.ok()
|
|
.and_then(|t| serde_json::from_str(&t).ok())
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let meta = read_meta(dir);
|
|
let target = status
|
|
.get("target")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.or_else(|| if meta.target.is_empty() { None } else { Some(meta.target.clone()) })
|
|
.unwrap_or_else(|| dir.file_name().and_then(|s| s.to_str()).unwrap_or("target").to_string());
|
|
write_all(&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 meta = EngagementMeta { asset: "OWASP Juice Shop".into(), ..Default::default() };
|
|
let md = markdown("http://t", &[confirmed, review.clone()], &meta);
|
|
assert!(md.contains("## Confirmed findings (1)"));
|
|
assert!(md.contains("## Needs human review (1)"));
|
|
assert!(md.contains("Needs human review") && md.contains("below vote quorum"));
|
|
assert!(md.contains("OWASP Juice Shop")); // asset named, not just URL
|
|
|
|
let js = json_report("http://t", &[review], "run1", &meta);
|
|
let v: serde_json::Value = serde_json::from_str(&js).unwrap();
|
|
assert_eq!(v["summary"]["needs_review"], 1);
|
|
assert_eq!(v["summary"]["confirmed"], 0);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod wrap_tests {
|
|
use super::*;
|
|
const ZWSP: char = '\u{200b}';
|
|
|
|
/// The regression: evidence is prose with machine tokens in it, and the
|
|
/// first implementation broke the prose.
|
|
#[test]
|
|
fn ordinary_words_are_never_split() {
|
|
let prose = "token not rotated/consumed, so ASP.NET Core Identity configured with lockoutOnFailure=false";
|
|
let out = wrappable(prose);
|
|
assert!(!out.contains(ZWSP), "no word here is long enough to need breaking: {out:?}");
|
|
assert_eq!(out, prose);
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_encoded_payload_gets_break_points() {
|
|
let payload = "Input.Nome=poc&Input.Email=victim@example.test&Input.Password=NrSplt!Test123&Input.ConfirmPassword=NrSplt!Test123&__RequestVerificationToken=CfDJ8A0uCaR&_handler=register";
|
|
let out = wrappable(payload);
|
|
assert!(out.contains(ZWSP), "a 170-character token must be breakable");
|
|
// The text itself is unchanged once the invisible marks are removed.
|
|
assert_eq!(out.replace(ZWSP, ""), payload);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unbroken_blob_is_still_breakable() {
|
|
let blob = "A".repeat(200);
|
|
let out = wrappable(&blob);
|
|
assert!(out.contains(ZWSP), "a base64 blob has no separators and still must wrap");
|
|
assert_eq!(out.replace(ZWSP, ""), blob);
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_and_newlines_survive_untouched() {
|
|
let s = "line one\n indented two\ttabbed";
|
|
assert_eq!(wrappable(s), s);
|
|
}
|
|
|
|
#[test]
|
|
fn a_url_just_under_the_limit_is_left_alone() {
|
|
let url = "https://arenahockeypara.com.br/Account/Login";
|
|
assert_eq!(wrappable(url), url, "a normal URL fits and must not be peppered with breaks");
|
|
}
|
|
}
|