From d42e9ff8e88fd7d53d7e56e990ef028463e0649f Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 23 Aug 2026 14:37:28 -0300 Subject: [PATCH] fix(web): global [hidden] bug, progress bar, F5 persistence, finding detail + PoC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real front-end bugs found and fixed: - [hidden] never worked on any element whose class also sets 'display' (every .btn, .chip, ...): the browser's built-in '[hidden]{display:none}' rule and an author rule of equal specificity tie, and the later one in the cascade wins — so 'Next' stayed visible on the Review step alongside 'Start Exploitation', and 'Open report'/'Stop' rendered during 'starting'. Fixed with a single global '[hidden]{display:none!important}' override. - Progress bar was functionally correct but easy to miss (thin, 0%-width, low-contrast track) and gave no feedback while the agent count is still unknown (recon phase). Added a border for visibility and an indeterminate sliding-segment state for the 'agents: ?' window. - A live run watched in the browser was lost on F5 (jumped back to the wizard) even though the job keeps running server-side. The active job id now persists in localStorage; on load the app reconnects the SSE stream (the server replays its full event buffer) instead of losing the view. New: - Findings are now clickable — a detail modal shows every Finding field (CWE/CVSS/OWASP/MITRE/stage/exploitability/confidence/votes/review status/ auth context/account/agent), endpoint+payload, evidence, impact, business impact, remediation, and chains_from — in both the live run and past-run detail views. - PoC surfacing: the finding modal looks up any script the run wrote to pocs/ that's cited in the finding's evidence (per the harness's own doctrine — see pipeline.rs change below), fetches and previews it inline, with a link to open the raw file. Live runs poll for new PoC files every 5s once the run id is known. - Pinned-leads confirmation: the live run header now states plainly how many leads were pinned (and their names) or that selection is auto (recon-driven) — this was previously buried in the scrolling activity log behind the harness's unconditional 'Loaded 435 agents' library-size line, which describes the full agent library, not what will actually run. Harness doctrine (crates/harness/src/pipeline.rs, pocs_line()): PoC-writing for black-box findings was previously conditioned on 'when an issue needs a custom multi-step exploit/script' — vague enough that a straightforward finding (single-request XSS/SQLi/IDOR) often got no PoC file at all. Now required for every confirmed Medium+ finding, one standalone .py/.sh script per finding, and explicit about citing the exact file name in the finding's evidence field (which is what the web UI now matches on to link a PoC to its finding). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd --- neurosploit-rs/crates/harness/src/pipeline.rs | 10 +- web/public/app.js | 157 ++++++++++++++++-- web/public/index.html | 29 +++- web/public/style.css | 16 +- web/server.js | 6 +- 5 files changed, 201 insertions(+), 17 deletions(-) diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index c5621bb..ba8f096 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -175,9 +175,13 @@ fn proxy_line() -> String { fn pocs_line() -> String { match std::env::var("NEUROSPLOIT_POCS").ok().filter(|v| !v.trim().is_empty()) { Some(d) => format!( - "POCS: when an issue needs a custom multi-step exploit/script to prove it, WRITE a runnable PoC \ - (curl/python/bash) to {d}/ with a short header comment (target, what it proves, usage), run it to \ - confirm, and reference the file path in the finding evidence.\n "), + "POCS (required for every confirmed Medium+ finding): before reporting it, WRITE a standalone, \ + runnable PoC to {d}/.py or {d}/.sh (prefer Python or Bash — one file per \ + finding, not per step) that reproduces the vulnerability end-to-end: target, exact payload/request, \ + and the observable proof (response snippet, status code, timing, etc.). Header comment: what it \ + proves, how to run it. Actually RUN it once to confirm it works before citing it. Put the exact file \ + name (e.g. `pocs/idor_order_id.py`) in the finding's `evidence` field so the report and UI can link \ + it — a finding without a cited PoC path looks unproven.\n "), None => String::new(), } } diff --git a/web/public/app.js b/web/public/app.js index 5cb56ff..f748c3f 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -357,7 +357,7 @@ async function startExploitation() { $('#btnLaunch').textContent = 'Starting…'; try { const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - attachLiveJob(id, body.target || body.repo, name); + attachLiveJob(id, body.target || body.repo, name, body.agents); } catch (e) { alert('Failed to start: ' + e.message); } finally { @@ -380,9 +380,16 @@ function bindRunTabs(scopeEl) { bindRunTabs($('#liveView')); bindRunTabs($('#detailView')); -function attachLiveJob(id, target, name) { +const ACTIVE_JOB_KEY = 'ns-active-job'; + +function attachLiveJob(id, target, name, pinnedAgents) { if (state.currentJob?.es) state.currentJob.es.close(); - state.currentJob = { id, es: null, findings: [], target, name, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null, runId: null }; + clearInterval(state.currentJob?.pocPoll); + state.currentJob = { + id, es: null, findings: [], target, name, phase: 'starting', agents: 0, agentsDone: 0, + reportUrl: null, runId: null, pinnedAgents: pinnedAgents || [], pocs: [], pocPoll: null, + }; + localStorage.setItem(ACTIVE_JOB_KEY, id); show($('#wizardView'), false); show($('#detailView'), false); @@ -391,13 +398,16 @@ function attachLiveJob(id, target, name) { $('#liveTargetSub').textContent = name ? target : ''; $('#livePhase').textContent = 'starting'; $('#phaseDot').style.background = ''; + $('#phaseDot').classList.remove('static'); $('#liveFindingsTable tbody').innerHTML = ''; $('#liveAttackPath').innerHTML = ''; $('#logList').innerHTML = ''; $('#liveFindingsCount').textContent = '0'; show($('#liveFindingsEmpty'), true); + $('#progressBar').classList.add('indeterminate'); $('#progressFill').style.width = '0%'; - $('#progressLabel').textContent = '0 / 0 agents'; + $('#progressLabel').textContent = '0 / ? agents'; + updatePinnedLine(); show($('#btnOpenReport'), false); const es = new EventSource(`/api/exploit/${id}/events`); @@ -405,8 +415,48 @@ function attachLiveJob(id, target, name) { es.addEventListener('log', (e) => appendLog(JSON.parse(e.data).line)); es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding)); es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data))); - es.addEventListener('done', (e) => { applySnapshot(JSON.parse(e.data)); es.close(); refreshRuns(); }); + es.addEventListener('done', (e) => { + applySnapshot(JSON.parse(e.data)); + es.close(); + clearInterval(state.currentJob.pocPoll); + refreshRuns(); + }); es.onerror = () => { /* EventSource auto-retries; the server replays its buffer on reconnect */ }; + + // PoC scripts land in runs//pocs/ during the run — poll for them once + // the CLI's own run id is known (see applySnapshot), so the finding modal + // can offer a generated PoC as soon as one exists, not just after the run + // finishes. + state.currentJob.pocPoll = setInterval(async () => { + if (!state.currentJob?.runId) return; + try { + const detail = await api(`/api/runs/${state.currentJob.runId}`); + state.currentJob.pocs = detail.pocs || []; + } catch { /* run dir not written yet */ } + }, 5000); +} + +function updatePinnedLine() { + const n = state.currentJob?.pinnedAgents?.length || 0; + $('#livePinned').textContent = n + ? `${n} pinned lead(s): ${state.currentJob.pinnedAgents.join(', ')}` + : 'auto — recon-driven agent selection (no leads pinned)'; +} + +// Resume a live view across a page reload: the server-side job outlives the +// browser tab, so re-attaching just reconnects SSE — the server replays its +// full event buffer (log + findings) on connect. +async function tryResumeActiveJob() { + const id = localStorage.getItem(ACTIVE_JOB_KEY); + if (!id) return false; + try { + const snap = await api(`/api/exploit/${id}`); + attachLiveJob(id, snap.target, snap.name, snap.pinnedAgents); + return true; + } catch { + localStorage.removeItem(ACTIVE_JOB_KEY); // job no longer exists (server restarted, etc.) + return false; + } } function appendLog(line) { @@ -418,8 +468,8 @@ function appendLog(line) { list.scrollTop = list.scrollHeight; } -function findingRow(f) { - return ` +function findingRow(f, idx) { + return ` ${esc(f.severity)} ${esc(f.title)} ${esc(f.endpoint)} @@ -429,9 +479,23 @@ function findingRow(f) { `; } +// Click any finding row (live or past-run) to open the full detail modal — +// evidence/impact/remediation/chain plus any PoC script the run wrote. +function bindFindingTableClicks(tbodySel, getFindings, getRunId, getPocs) { + $(tbodySel).addEventListener('click', (e) => { + const tr = e.target.closest('tr'); + if (!tr) return; + const f = getFindings()[Number(tr.dataset.idx)]; + if (f) openFindingModal(f, getPocs(), getRunId()); + }); +} +bindFindingTableClicks('#liveFindingsTable tbody', () => state.currentJob?.findings || [], () => state.currentJob?.runId, () => state.currentJob?.pocs || []); +bindFindingTableClicks('#detailFindingsTable tbody', () => state.detailFindings || [], () => state.currentDetailId, () => state.detailPocs || []); + function addFinding(f) { + const idx = state.currentJob.findings.length; state.currentJob.findings.push(f); - $('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f)); + $('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f, idx)); $('#liveFindingsCount').textContent = state.currentJob.findings.length; show($('#liveFindingsEmpty'), false); renderAttackPath($('#liveAttackPath'), state.currentJob.findings); @@ -440,7 +504,12 @@ function addFinding(f) { function applySnapshot(snap) { $('#livePhase').textContent = snap.phase; state.currentJob.runId = snap.runId; + if (snap.pinnedAgents?.length && !state.currentJob.pinnedAgents.length) { + state.currentJob.pinnedAgents = snap.pinnedAgents; + updatePinnedLine(); + } $('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`; + $('#progressBar').classList.toggle('indeterminate', !snap.agents); if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`; if (snap.reportUrl && snap.runId) { $('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`; @@ -453,14 +522,77 @@ $('#btnStopRun').addEventListener('click', async () => { if (!state.currentJob) return; await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' }); }); -$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#wizardView'), true); }); +function leaveLiveJob() { + localStorage.removeItem(ACTIVE_JOB_KEY); + clearInterval(state.currentJob?.pocPoll); + state.currentJob?.es?.close(); +} +$('#btnBackToBoard').addEventListener('click', () => { leaveLiveJob(); show($('#liveView'), false); show($('#wizardView'), true); }); $('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#wizardView'), true); }); -$('#btnNewEngagement').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#wizardView'), true); }); +$('#btnNewEngagement').addEventListener('click', () => { leaveLiveJob(); clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#wizardView'), true); }); // --------------------------------------------------------------------------- // Generative Attack Path Chaining // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Finding detail modal — full evidence/impact/remediation + any PoC script +// --------------------------------------------------------------------------- + +function openFindingModal(f, pocs, runId) { + $('#fmSev').className = `sev ${sevClass(f.severity)}`; + $('#fmSev').textContent = f.severity || 'info'; + $('#fmTitle').textContent = f.title || '(untitled finding)'; + + const meta = [ + ['CWE', f.cwe], ['CVSS', f.cvss], ['OWASP', f.owasp], ['MITRE', f.mitre], + ['Stage', f.stage], ['Exploitability', f.exploitability], + ['Confidence', f.confidence ? f.confidence.toFixed(2) : ''], ['Votes', f.votes], + ['Review status', f.review_status], ['Auth context', f.auth_context], + ['Account', f.account], ['Agent', f.agent], + ]; + $('#fmMeta').innerHTML = meta.map(([k, v]) => + `
${esc(k)}
${esc(v || '—')}
`).join(''); + + const section = (label, text) => text + ? `
${esc(text)}
` + : ''; + $('#fmSection-evidence').innerHTML = + section('Endpoint / payload', [f.endpoint, f.payload].filter(Boolean).join('\n\n')) + section('Evidence', f.evidence); + $('#fmSection-impact').innerHTML = section('Impact', [f.impact, f.business_impact].filter(Boolean).join('\n\n')); + $('#fmSection-remediation').innerHTML = section('Remediation', f.remediation); + $('#fmSection-chains').innerHTML = (f.chains_from || []).length + ? `
Chains from: ${esc(f.chains_from.join(', '))}
` : ''; + + // Proof of concept — doctrine tells agents to cite the PoC's file name in + // `evidence` (see pocs_line() in pipeline.rs), so match on that text first; + // fall back to whatever the run wrote to pocs/ if nothing was cited. + const citedIn = `${f.evidence || ''} ${f.payload || ''}`; + const matches = (pocs || []).filter((p) => citedIn.includes(p)); + const list = matches.length ? matches : (pocs || []); + const pocRoot = $('#fmPocList'); + if (!list.length) { + pocRoot.textContent = 'No PoC script written for this finding yet — the exploiting agent only writes one when the finding warrants a runnable repro.'; + } else { + pocRoot.innerHTML = list.map((name) => ` +
+ pocs/${esc(name)} + Open raw +
+
loading…
+ `).join(''); + for (const name of list) { + fetch(`/api/runs/${runId}/asset/pocs/${name}`).then((r) => r.text()).then((txt) => { + const pre = pocRoot.querySelector(`pre[data-poc="${CSS.escape(name)}"]`); + if (pre) pre.textContent = txt.slice(0, 4000); + }).catch(() => {}); + } + } + show($('#findingModal'), true); +} +$('#btnCloseFinding').addEventListener('click', () => show($('#findingModal'), false)); +$('#findingModal').addEventListener('click', (e) => { if (e.target.id === 'findingModal') show($('#findingModal'), false); }); + const KILL_CHAIN_STAGES = ['recon', 'initial-access', 'execution', 'privesc', 'lateral', 'exfil', 'impact']; function renderAttackPath(container, findings) { @@ -575,8 +707,10 @@ async function loadDetail(id) { $('#detailTargetSub').textContent = detail.name ? target : ''; $('#detailState').textContent = detail.status?.state || 'unknown'; $('#detailFindingsCount').textContent = detail.findings.length; + state.detailFindings = detail.findings; + state.detailPocs = detail.pocs || []; const tbody = $('#detailFindingsTable tbody'); - tbody.innerHTML = detail.findings.map(findingRow).join(''); + tbody.innerHTML = detail.findings.map((f, i) => findingRow(f, i)).join(''); show($('#detailFindingsEmpty'), detail.findings.length === 0); renderAttackPath($('#detailAttackPath'), detail.findings); const reportLink = $('#detailOpenReport'); @@ -713,6 +847,7 @@ async function boot() { $('#sbVersion').textContent = `v${meta.version || '4.0.0'}`; await Promise.all([loadAgents(), loadProviders()]); await refreshRuns(); + await tryResumeActiveJob(); // survive an F5 while watching a live run setInterval(refreshRuns, 6000); } boot(); diff --git a/web/public/index.html b/web/public/index.html index de78500..af176d4 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -204,6 +204,7 @@
starting
+
@@ -212,7 +213,7 @@
-
+
0 / 0 agents