diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 35f8a9e..6c2b5dc 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -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}") }; diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 7c27d84..a4d9c99 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -771,7 +771,14 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender Vec { 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")), diff --git a/neurosploit-rs/crates/harness/src/report.rs b/neurosploit-rs/crates/harness/src/report.rs index 0afb201..1cd48e5 100644 --- a/neurosploit-rs/crates/harness/src/report.rs +++ b/neurosploit-rs/crates/harness/src/report.rs @@ -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 = 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 { + let findings: Vec = 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::*; diff --git a/web/public/app.js b/web/public/app.js index 2cc8308..b8b59b4 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -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); }); diff --git a/web/public/index.html b/web/public/index.html index 1e6e470..838d5dc 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -306,6 +306,7 @@
+
diff --git a/web/server.js b/web/server.js index 79fac86..d0ec254 100644 --- a/web/server.js +++ b/web/server.js @@ -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());