mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-30 04:51:50 +02:00
feat(report): rebuild reports on demand, from the CLI and the web
A PDF was only ever produced while a run was finishing. If `typst` was missing at that moment — or the template improved afterwards — the operator had no way to get one without re-running the whole engagement against the target. report::rebuild() regenerates every artifact (md · json · html · pdf) from the findings already on disk, exposed as `neurosploit rebuild <run-id|dir>` and as POST /api/runs/:id/report with a "Generate report" button in the run view. The endpoint shells out to the harness rather than reimplementing report generation in JavaScript, so there is one implementation instead of two that drift, and it says plainly when the PDF was skipped for want of `typst` instead of handing back a link to a file that was never produced. Also fixes write_all() to pass the run's pocs/ listing into the HTML report, so a rebuilt report links the scripts each finding cites — the run-time path already did this and the rebuild path silently did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
481a4eb1b9
commit
61ae3bc74d
@@ -115,6 +115,12 @@ enum Cmd {
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
},
|
||||
/// Rebuild a finished run's report artifacts (md · json · html · pdf) from
|
||||
/// its findings, without re-running the engagement.
|
||||
Rebuild {
|
||||
/// Run id (`ns-…`) or a path to the run directory.
|
||||
run: String,
|
||||
},
|
||||
/// Issue or inspect a signed capability token (the engagement's authorization).
|
||||
Capability {
|
||||
#[command(subcommand)]
|
||||
@@ -432,6 +438,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::Rebuild { run } => {
|
||||
// Accept either a path or a bare run id, resolved against the same
|
||||
// runs root the engagement wrote to.
|
||||
let dir = std::path::PathBuf::from(&run);
|
||||
let dir = if dir.is_dir() { dir } else { base.join("runs").join(&run) };
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!("no such run directory: {}", dir.display());
|
||||
}
|
||||
match harness::report::rebuild(&dir) {
|
||||
Ok(p) => println!(" report rebuilt → {}", p.display()),
|
||||
Err(e) => anyhow::bail!("rebuild failed: {e}"),
|
||||
}
|
||||
}
|
||||
Cmd::Capability { cmd } => handle_capability(cmd)?,
|
||||
Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, in_scope, environment, policy, jira, only, verbose } => {
|
||||
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||
|
||||
@@ -771,7 +771,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
- `remediation`: the concrete change, naming the control (parameterised query, server-side authorisation check, allowlist), not \"sanitise input\".\n\
|
||||
- `repro_steps`: an ORDERED array of literal commands someone can paste one by one from a clean shell — baseline request first, then the attack, then how to read the result. Include the real URL and the real payload.\n\
|
||||
- `evidence`: the concrete proof (request/response excerpt with status, key headers and the decisive part of the body). \
|
||||
A PoC script is an EXTRA artifact, never a substitute for these steps. \
|
||||
A PoC script is an EXTRA artifact, never a substitute for these steps.\n\
|
||||
- `evidence_data`: the artifacts the harness verifies WITHOUT a model. Shape:\n\
|
||||
{{\"baseline\":{{\"method\":\"GET\",\"url\":\"...\",\"status\":200,\"body\":\"...\",\"headers\":{{\"set-cookie\":\"...\"}},\"request_headers\":{{}},\"elapsed_ms\":120,\"identity\":\"\"}},\n\
|
||||
\"attack\":{{same shape, the request carrying the payload}},\n\
|
||||
\"repeats\":[{{same shape, the attack repeated}}],\n\
|
||||
\"marker\":\"a unique token YOU chose\",\"marker_observed\":true|false,\"browser_executed\":true|false,\"callback_received\":true|false,\n\
|
||||
\"identity_a\":{{the owner's response}},\"identity_b\":{{another identity requesting the SAME resource}}}}\n\
|
||||
Fill only what applies to the class (see the evidence contract above); truncate bodies to the decisive part. \
|
||||
`screenshots` is an array of proof-image paths you saved into the evidence dir (see EVIDENCE SCREENSHOTS above); omit or leave empty when you captured none. \
|
||||
Set `auth_context` to \"authenticated\" or \"unauthenticated\"; set `account` to the test user/role you used (if any); \
|
||||
for a created test account set `secret` to its generated password (it is stored in the run vault and masked in the report).",
|
||||
@@ -1923,6 +1930,11 @@ fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
|
||||
repro_steps: o.get("repro_steps").and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(|t| t.to_string())).collect())
|
||||
.unwrap_or_default(),
|
||||
// Structured artifacts for the deterministic validators. Without
|
||||
// this the evidence contract in the prompt goes to an agent that
|
||||
// dutifully fills it in and a parser that throws it away — the
|
||||
// whole validation engine would sit idle on live runs.
|
||||
evidence_data: o.get("evidence_data").and_then(|v| serde_json::from_value(v.clone()).ok()),
|
||||
impact: s(o, "impact"),
|
||||
remediation: s(o, "remediation"),
|
||||
confidence: conf(o.get("confidence")),
|
||||
|
||||
@@ -721,10 +721,40 @@ pub fn write_all(target: &str, findings: &[Finding], dir: &Path) -> std::io::Res
|
||||
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))?;
|
||||
std::fs::write(dir.join("report.html"), html(target, findings, &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 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();
|
||||
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::*;
|
||||
|
||||
@@ -784,6 +784,28 @@ function leaveLiveJob() {
|
||||
termSyncTargets();
|
||||
}
|
||||
$('#btnBackToBoard').addEventListener('click', () => { leaveLiveJob(); show($('#liveView'), false); show($('#dashView'), false); show($('#wizardView'), true); });
|
||||
// Regenerating from the evidence already on disk, rather than re-running the
|
||||
// engagement: a run whose PDF was never produced (no `typst` at the time, or a
|
||||
// since-improved template) would otherwise be unreportable.
|
||||
$('#btnBuildReport').addEventListener('click', async () => {
|
||||
const id = state.currentDetailId;
|
||||
if (!id) return;
|
||||
const btn = $('#btnBuildReport');
|
||||
const label = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating…';
|
||||
try {
|
||||
const r = await api(`/api/runs/${encodeURIComponent(id)}/report`, { method: 'POST' });
|
||||
toast(r.pdf ? 'Report rebuilt — PDF ready.' : (r.note || 'Report rebuilt.'), r.pdf ? 'ok' : 'warn', 7000);
|
||||
await loadDetail(id);
|
||||
} catch (e) {
|
||||
toast(`Couldn't generate the report: ${e.message}`, 'error', 9000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = label;
|
||||
}
|
||||
});
|
||||
|
||||
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#dashView'), false); show($('#wizardView'), true); });
|
||||
$('#btnNewEngagement').addEventListener('click', () => { leaveLiveJob(); clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#wizardView'), true); });
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
<div class="run-actions">
|
||||
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
|
||||
<a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a>
|
||||
<button class="btn" id="btnBuildReport" title="Regenerate this run's report from its findings">Generate report</button>
|
||||
<a class="btn" id="detailOpenAudit" target="_blank" hidden title="Every action this run took, hash-chained">Audit trail</a>
|
||||
<button class="btn" id="btnDetailBack">← New engagement</button>
|
||||
</div>
|
||||
|
||||
@@ -1038,6 +1038,38 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- report rebuild (generate/refresh the PDF for a finished run) ----
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/report$/);
|
||||
if (req.method === 'POST' && m) {
|
||||
const id = decodeURIComponent(m[1]);
|
||||
const dir = safeRunDir(id);
|
||||
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
|
||||
if (!BIN) return sendJson(res, 500, { error: 'neurosploit binary not found — run `cargo build --release` in neurosploit-rs/' });
|
||||
// The harness owns report generation (Typst template, severity ordering,
|
||||
// the evidence sections); shelling out to it keeps one implementation
|
||||
// instead of a second, drifting one in JavaScript.
|
||||
const out = await new Promise((resolve) => {
|
||||
const child = spawn(BIN, ['rebuild', dir], { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||
let buf = '';
|
||||
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
|
||||
child.stderr.on('data', (c) => { buf += c.toString('utf8'); });
|
||||
child.on('close', (code) => resolve({ code, buf }));
|
||||
child.on('error', (e) => resolve({ code: -1, buf: e.message }));
|
||||
});
|
||||
const built = ['report.pdf', 'report.html', 'report.md', 'report.json'].filter((f) => fs.existsSync(path.join(dir, f)));
|
||||
if (out.code !== 0 && !built.includes('report.pdf')) {
|
||||
return sendJson(res, 502, { error: stripAnsi(out.buf).trim() || 'rebuild failed', built });
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
built,
|
||||
// Typst is optional; saying so beats handing back a link to a file that
|
||||
// was never produced.
|
||||
pdf: built.includes('report.pdf'),
|
||||
note: built.includes('report.pdf') ? '' : 'PDF needs the `typst` binary on PATH — the HTML and Markdown reports were rebuilt.',
|
||||
});
|
||||
}
|
||||
|
||||
// ---- aggregate stats for the dashboard ----
|
||||
if (req.method === 'GET' && p === '/api/stats') {
|
||||
return sendJson(res, 200, await stats());
|
||||
|
||||
Reference in New Issue
Block a user