mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-27 20:00:26 +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
@@ -175,9 +175,13 @@ fn proxy_line() -> String {
|
|||||||
fn pocs_line() -> String {
|
fn pocs_line() -> String {
|
||||||
match std::env::var("NEUROSPLOIT_POCS").ok().filter(|v| !v.trim().is_empty()) {
|
match std::env::var("NEUROSPLOIT_POCS").ok().filter(|v| !v.trim().is_empty()) {
|
||||||
Some(d) => format!(
|
Some(d) => format!(
|
||||||
"POCS: when an issue needs a custom multi-step exploit/script to prove it, WRITE a runnable PoC \
|
"POCS (required for every confirmed Medium+ finding): before reporting it, WRITE a standalone, \
|
||||||
(curl/python/bash) to {d}/ with a short header comment (target, what it proves, usage), run it to \
|
runnable PoC to {d}/<short-slug>.py or {d}/<short-slug>.sh (prefer Python or Bash — one file per \
|
||||||
confirm, and reference the file path in the finding evidence.\n "),
|
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(),
|
None => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-11
@@ -357,7 +357,7 @@ async function startExploitation() {
|
|||||||
$('#btnLaunch').textContent = 'Starting…';
|
$('#btnLaunch').textContent = 'Starting…';
|
||||||
try {
|
try {
|
||||||
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
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) {
|
} catch (e) {
|
||||||
alert('Failed to start: ' + e.message);
|
alert('Failed to start: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -380,9 +380,16 @@ function bindRunTabs(scopeEl) {
|
|||||||
bindRunTabs($('#liveView'));
|
bindRunTabs($('#liveView'));
|
||||||
bindRunTabs($('#detailView'));
|
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();
|
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($('#wizardView'), false);
|
||||||
show($('#detailView'), false);
|
show($('#detailView'), false);
|
||||||
@@ -391,13 +398,16 @@ function attachLiveJob(id, target, name) {
|
|||||||
$('#liveTargetSub').textContent = name ? target : '';
|
$('#liveTargetSub').textContent = name ? target : '';
|
||||||
$('#livePhase').textContent = 'starting';
|
$('#livePhase').textContent = 'starting';
|
||||||
$('#phaseDot').style.background = '';
|
$('#phaseDot').style.background = '';
|
||||||
|
$('#phaseDot').classList.remove('static');
|
||||||
$('#liveFindingsTable tbody').innerHTML = '';
|
$('#liveFindingsTable tbody').innerHTML = '';
|
||||||
$('#liveAttackPath').innerHTML = '';
|
$('#liveAttackPath').innerHTML = '';
|
||||||
$('#logList').innerHTML = '';
|
$('#logList').innerHTML = '';
|
||||||
$('#liveFindingsCount').textContent = '0';
|
$('#liveFindingsCount').textContent = '0';
|
||||||
show($('#liveFindingsEmpty'), true);
|
show($('#liveFindingsEmpty'), true);
|
||||||
|
$('#progressBar').classList.add('indeterminate');
|
||||||
$('#progressFill').style.width = '0%';
|
$('#progressFill').style.width = '0%';
|
||||||
$('#progressLabel').textContent = '0 / 0 agents';
|
$('#progressLabel').textContent = '0 / ? agents';
|
||||||
|
updatePinnedLine();
|
||||||
show($('#btnOpenReport'), false);
|
show($('#btnOpenReport'), false);
|
||||||
|
|
||||||
const es = new EventSource(`/api/exploit/${id}/events`);
|
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('log', (e) => appendLog(JSON.parse(e.data).line));
|
||||||
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
|
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
|
||||||
es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data)));
|
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 */ };
|
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) {
|
function appendLog(line) {
|
||||||
@@ -418,8 +468,8 @@ function appendLog(line) {
|
|||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findingRow(f) {
|
function findingRow(f, idx) {
|
||||||
return `<tr>
|
return `<tr data-idx="${idx}">
|
||||||
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
||||||
<td>${esc(f.title)}</td>
|
<td>${esc(f.title)}</td>
|
||||||
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
|
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
|
||||||
@@ -429,9 +479,23 @@ function findingRow(f) {
|
|||||||
</tr>`;
|
</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) {
|
function addFinding(f) {
|
||||||
|
const idx = state.currentJob.findings.length;
|
||||||
state.currentJob.findings.push(f);
|
state.currentJob.findings.push(f);
|
||||||
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f));
|
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f, idx));
|
||||||
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
|
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
|
||||||
show($('#liveFindingsEmpty'), false);
|
show($('#liveFindingsEmpty'), false);
|
||||||
renderAttackPath($('#liveAttackPath'), state.currentJob.findings);
|
renderAttackPath($('#liveAttackPath'), state.currentJob.findings);
|
||||||
@@ -440,7 +504,12 @@ function addFinding(f) {
|
|||||||
function applySnapshot(snap) {
|
function applySnapshot(snap) {
|
||||||
$('#livePhase').textContent = snap.phase;
|
$('#livePhase').textContent = snap.phase;
|
||||||
state.currentJob.runId = snap.runId;
|
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`;
|
$('#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.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
|
||||||
if (snap.reportUrl && snap.runId) {
|
if (snap.reportUrl && snap.runId) {
|
||||||
$('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`;
|
$('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`;
|
||||||
@@ -453,14 +522,77 @@ $('#btnStopRun').addEventListener('click', async () => {
|
|||||||
if (!state.currentJob) return;
|
if (!state.currentJob) return;
|
||||||
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
|
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); });
|
$('#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
|
// 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'];
|
const KILL_CHAIN_STAGES = ['recon', 'initial-access', 'execution', 'privesc', 'lateral', 'exfil', 'impact'];
|
||||||
|
|
||||||
function renderAttackPath(container, findings) {
|
function renderAttackPath(container, findings) {
|
||||||
@@ -575,8 +707,10 @@ async function loadDetail(id) {
|
|||||||
$('#detailTargetSub').textContent = detail.name ? target : '';
|
$('#detailTargetSub').textContent = detail.name ? target : '';
|
||||||
$('#detailState').textContent = detail.status?.state || 'unknown';
|
$('#detailState').textContent = detail.status?.state || 'unknown';
|
||||||
$('#detailFindingsCount').textContent = detail.findings.length;
|
$('#detailFindingsCount').textContent = detail.findings.length;
|
||||||
|
state.detailFindings = detail.findings;
|
||||||
|
state.detailPocs = detail.pocs || [];
|
||||||
const tbody = $('#detailFindingsTable tbody');
|
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);
|
show($('#detailFindingsEmpty'), detail.findings.length === 0);
|
||||||
renderAttackPath($('#detailAttackPath'), detail.findings);
|
renderAttackPath($('#detailAttackPath'), detail.findings);
|
||||||
const reportLink = $('#detailOpenReport');
|
const reportLink = $('#detailOpenReport');
|
||||||
@@ -713,6 +847,7 @@ async function boot() {
|
|||||||
$('#sbVersion').textContent = `v${meta.version || '4.0.0'}`;
|
$('#sbVersion').textContent = `v${meta.version || '4.0.0'}`;
|
||||||
await Promise.all([loadAgents(), loadProviders()]);
|
await Promise.all([loadAgents(), loadProviders()]);
|
||||||
await refreshRuns();
|
await refreshRuns();
|
||||||
|
await tryResumeActiveJob(); // survive an F5 while watching a live run
|
||||||
setInterval(refreshRuns, 6000);
|
setInterval(refreshRuns, 6000);
|
||||||
}
|
}
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
+28
-1
@@ -204,6 +204,7 @@
|
|||||||
<div class="run-target" id="liveTarget">—</div>
|
<div class="run-target" id="liveTarget">—</div>
|
||||||
<div class="run-meta" id="liveTargetSub" style="font-family: var(--mono);"></div>
|
<div class="run-meta" id="liveTargetSub" style="font-family: var(--mono);"></div>
|
||||||
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
||||||
|
<div class="run-meta" id="livePinned" style="font-family: var(--mono);"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="run-actions">
|
<div class="run-actions">
|
||||||
<a class="btn" id="btnOpenReport" target="_blank" hidden>Open report</a>
|
<a class="btn" id="btnOpenReport" target="_blank" hidden>Open report</a>
|
||||||
@@ -212,7 +213,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="progress-wrap">
|
<div class="progress-wrap">
|
||||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
<div class="progress-bar" id="progressBar"><div class="progress-fill" id="progressFill"></div></div>
|
||||||
<div class="progress-label" id="progressLabel">0 / 0 agents</div>
|
<div class="progress-label" id="progressLabel">0 / 0 agents</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="run-tabs">
|
<nav class="run-tabs">
|
||||||
@@ -294,6 +295,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ FINDING DETAIL MODAL ============ -->
|
||||||
|
<div class="modal-overlay" id="findingModal" hidden>
|
||||||
|
<div class="modal" style="width: 760px;">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<span class="sev" id="fmSev">—</span>
|
||||||
|
<span class="title" id="fmTitle" style="margin-left:8px;">—</span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" id="btnCloseFinding">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="review-grid" id="fmMeta" style="margin-bottom: var(--sp-5);"></div>
|
||||||
|
|
||||||
|
<div id="fmSection-evidence"></div>
|
||||||
|
<div id="fmSection-impact"></div>
|
||||||
|
<div id="fmSection-remediation"></div>
|
||||||
|
<div id="fmSection-chains"></div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Proof of concept</label>
|
||||||
|
<div id="fmPocList" class="field-help">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============ REPL DRAWER ============ -->
|
<!-- ============ REPL DRAWER ============ -->
|
||||||
<div class="repl-drawer" id="replDrawer" hidden>
|
<div class="repl-drawer" id="replDrawer" hidden>
|
||||||
<div class="repl-head">
|
<div class="repl-head">
|
||||||
|
|||||||
+15
-1
@@ -65,6 +65,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
/* The [hidden] attribute must always win. Any component class that sets its
|
||||||
|
own `display` (buttons, chips, flex/grid containers, ...) has the SAME
|
||||||
|
specificity as the browser's built-in `[hidden] { display: none }` rule —
|
||||||
|
whichever is declared later in the cascade wins, which silently breaks
|
||||||
|
`el.hidden = true` on anything already styled with `display`. Force it. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
html, body { margin: 0; padding: 0; height: 100%; }
|
html, body { margin: 0; padding: 0; height: 100%; }
|
||||||
body {
|
body {
|
||||||
background: var(--bg); color: var(--text); font-family: var(--sans);
|
background: var(--bg); color: var(--text); font-family: var(--sans);
|
||||||
@@ -288,8 +294,12 @@ textarea { resize: vertical; min-height: 72px; }
|
|||||||
.run-actions { display: flex; gap: var(--sp-2); }
|
.run-actions { display: flex; gap: var(--sp-2); }
|
||||||
|
|
||||||
.progress-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-5) var(--sp-4); }
|
.progress-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-5) var(--sp-4); }
|
||||||
.progress-bar { flex: 1; height: 6px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
|
.progress-bar { flex: 1; height: 6px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--border); overflow: hidden; }
|
||||||
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
|
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
|
||||||
|
/* agent count unknown yet (still reconning) — slide a segment instead of
|
||||||
|
sitting at a static, easy-to-miss 0% fill */
|
||||||
|
.progress-bar.indeterminate .progress-fill { width: 30% !important; animation: progress-indeterminate 1.3s infinite linear; }
|
||||||
|
@keyframes progress-indeterminate { 0% { margin-left: -30%; } 100% { margin-left: 100%; } }
|
||||||
.progress-label { font-size: 11.5px; color: var(--text-faint); font-family: var(--mono); white-space: nowrap; }
|
.progress-label { font-size: 11.5px; color: var(--text-faint); font-family: var(--mono); white-space: nowrap; }
|
||||||
|
|
||||||
.run-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
|
.run-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||||
@@ -319,7 +329,11 @@ textarea { resize: vertical; min-height: 72px; }
|
|||||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
.data-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); font-weight: 600; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-strong); white-space: nowrap; }
|
.data-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); font-weight: 600; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-strong); white-space: nowrap; }
|
||||||
.data-table td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); vertical-align: top; }
|
.data-table td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
.data-table tbody tr { cursor: pointer; }
|
||||||
.data-table tbody tr:hover { background: var(--surface-2); }
|
.data-table tbody tr:hover { background: var(--surface-2); }
|
||||||
|
.poc-file { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
|
||||||
|
.poc-file .fn { font-family: var(--mono); font-size: 12px; flex: 1; }
|
||||||
|
.poc-pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); font-family: var(--mono); font-size: 11.5px; max-height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin-top: var(--sp-2); }
|
||||||
.data-table .col-endpoint { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
.data-table .col-endpoint { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.data-table .col-conf { font-family: var(--mono); text-align: right; }
|
.data-table .col-conf { font-family: var(--mono); text-align: right; }
|
||||||
.empty-state { padding: var(--sp-7) var(--sp-5); text-align: center; color: var(--text-faint); font-size: 12.5px; }
|
.empty-state { padding: var(--sp-7) var(--sp-5); text-align: center; color: var(--text-faint); font-size: 12.5px; }
|
||||||
|
|||||||
+5
-1
@@ -318,7 +318,8 @@ async function runDetail(id) {
|
|||||||
]);
|
]);
|
||||||
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
||||||
.filter((f) => fs.existsSync(path.join(dir, f)));
|
.filter((f) => fs.existsSync(path.join(dir, f)));
|
||||||
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets };
|
const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []);
|
||||||
|
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs };
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeRunDir(id) {
|
function safeRunDir(id) {
|
||||||
@@ -344,6 +345,7 @@ class Job extends EventEmitter {
|
|||||||
this.args = args;
|
this.args = args;
|
||||||
this.target = target || '';
|
this.target = target || '';
|
||||||
this.name = name || '';
|
this.name = name || '';
|
||||||
|
this.pinnedAgents = [];
|
||||||
this.runId = null; // ns-<ts>-<target> workdir basename, once known
|
this.runId = null; // ns-<ts>-<target> workdir basename, once known
|
||||||
this.phase = 'starting';
|
this.phase = 'starting';
|
||||||
this.findings = [];
|
this.findings = [];
|
||||||
@@ -366,6 +368,7 @@ class Job extends EventEmitter {
|
|||||||
id: this.id,
|
id: this.id,
|
||||||
target: this.target,
|
target: this.target,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
|
pinnedAgents: this.pinnedAgents,
|
||||||
runId: this.runId,
|
runId: this.runId,
|
||||||
phase: this.phase,
|
phase: this.phase,
|
||||||
findings: this.findings,
|
findings: this.findings,
|
||||||
@@ -451,6 +454,7 @@ async function startJob(body) {
|
|||||||
const credsPath = await materializeCreds(body, id);
|
const credsPath = await materializeCreds(body, id);
|
||||||
const args = buildArgs({ ...body, creds: credsPath });
|
const args = buildArgs({ ...body, creds: credsPath });
|
||||||
const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || '');
|
const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || '');
|
||||||
|
job.pinnedAgents = body.agents || [];
|
||||||
jobs.set(id, job);
|
jobs.set(id, job);
|
||||||
|
|
||||||
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||||
|
|||||||
Reference in New Issue
Block a user