mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-26 11:22:37 +02:00
fix(web): global [hidden] bug, progress bar, F5 persistence, finding detail + PoC
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
51c38c1db3
commit
d42e9ff8e8
+146
-11
@@ -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/<id>/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 `<tr>
|
||||
function findingRow(f, idx) {
|
||||
return `<tr data-idx="${idx}">
|
||||
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
||||
<td>${esc(f.title)}</td>
|
||||
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
|
||||
@@ -429,9 +479,23 @@ function findingRow(f) {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// 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]) =>
|
||||
`<div class="review-item"><div class="k">${esc(k)}</div><div class="v mono">${esc(v || '—')}</div></div>`).join('');
|
||||
|
||||
const section = (label, text) => text
|
||||
? `<div class="field-group"><label class="field-label">${esc(label)}</label><div class="poc-pre">${esc(text)}</div></div>`
|
||||
: '';
|
||||
$('#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
|
||||
? `<div class="field-help">Chains from: ${esc(f.chains_from.join(', '))}</div>` : '';
|
||||
|
||||
// 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) => `
|
||||
<div class="poc-file">
|
||||
<span class="fn">pocs/${esc(name)}</span>
|
||||
<a class="btn btn-sm" href="/api/runs/${esc(runId)}/asset/pocs/${esc(name)}" target="_blank">Open raw</a>
|
||||
</div>
|
||||
<pre class="poc-pre" data-poc="${esc(name)}">loading…</pre>
|
||||
`).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();
|
||||
|
||||
Reference in New Issue
Block a user