`;
const rows = card.querySelector('.agent-rows');
for (const a of group.agents) {
const row = document.createElement('div');
row.className = 'agent-row';
row.dataset.id = a.id;
row.dataset.title = (a.title + ' ' + a.name).toLowerCase();
row.innerHTML = `
${esc(a.title)}
${a.cwe ? `${esc(a.cwe)}` : ''}
`;
rows.appendChild(row);
}
card.querySelector('.cat-head').addEventListener('click', (e) => {
if (e.target.closest('.switch')) return;
const nowCollapsed = card.classList.toggle('collapsed');
if (nowCollapsed) state.expandedCats.delete(group.category);
else state.expandedCats.add(group.category);
});
const catToggle = card.querySelector('.cat-toggle');
// A partial selection (some but not all agents on) must look "partial",
// not "off" — an unchecked master switch reads as "category disabled"
// even when most of its agents are still on. Indeterminate = the middle
// state; clicking it from there selects everything (browser default).
catToggle.indeterminate = selCount > 0 && selCount < group.agents.length;
catToggle.addEventListener('change', (e) => {
const on = e.target.checked;
for (const a of group.agents) { if (on) state.selected.add(a.id); else state.selected.delete(a.id); }
renderBoard();
});
rows.querySelectorAll('.agent-toggle').forEach((input) => {
input.addEventListener('change', (e) => {
const id = e.target.dataset.id;
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
renderBoard();
});
});
root.appendChild(card);
}
updateChips();
applyFilters();
}
function allAgents() { return state.categories.flatMap((g) => g.agents); }
function updateChips() {
const total = allAgents().length;
$('#chipAll').textContent = total;
$('#chipSelected').textContent = state.selected.size;
$('#chipExcluded').textContent = total - state.selected.size;
}
function applyFilters() {
const q = state.search.trim().toLowerCase();
const narrowing = !!q || state.filter !== 'all';
$$('.agent-row').forEach((row) => {
const isSel = state.selected.has(row.dataset.id);
let visible = true;
if (state.filter === 'selected') visible = isSel;
if (state.filter === 'excluded') visible = !isSel;
if (visible && q) visible = row.dataset.title.includes(q);
row.classList.toggle('hidden-by-search', !visible);
});
let anyCardVisible = false;
$$('.cat-card').forEach((card) => {
const shown = $$('.agent-row', card).filter((r) => !r.classList.contains('hidden-by-search'));
card.style.display = shown.length ? '' : 'none';
if (shown.length) anyCardVisible = true;
// A search that matches leads inside a collapsed category has to open it —
// otherwise the hit count changes and nothing visibly happens.
if (narrowing && shown.length) card.classList.remove('collapsed');
else if (!narrowing && !state.expandedCats.has(card.dataset.category)) card.classList.add('collapsed');
const count = card.querySelector('.cat-match');
if (count) {
count.textContent = narrowing ? `${shown.length} match${shown.length === 1 ? '' : 'es'}` : '';
count.hidden = !narrowing;
}
});
show($('#leadsEmpty'), !anyCardVisible);
}
$$('.chip').forEach((chip) => chip.addEventListener('click', () => {
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
chip.classList.add('chip-active');
state.filter = chip.dataset.filter;
applyFilters();
}));
$('#leadSearch').addEventListener('input', (e) => { state.search = e.target.value; applyFilters(); });
function renderCustomLeads() {
const root = $('#customLeadsList');
root.innerHTML = state.customLeads.map((text, i) => `
${esc(text)}✕
`).join('');
// An empty list still occupied a gap the size of a card; hide it outright.
show(root, state.customLeads.length > 0);
$$('.custom-lead-chip .x', root).forEach((x) => x.addEventListener('click', () => {
state.customLeads.splice(Number(x.dataset.i), 1);
renderCustomLeads();
}));
}
// `prompt()` gave a one-line box with no room to describe a lead, no way to
// see the wizard behind it, and no place to report a generation failure.
function openLeadModal() {
$('#leadDesc').value = '';
fieldError('#errLead', '');
show($('#leadModal'), true);
$('#leadDesc').focus();
}
function closeLeadModal() { show($('#leadModal'), false); }
$('#btnCustomLead').addEventListener('click', openLeadModal);
$('#btnCloseLead').addEventListener('click', closeLeadModal);
$('#btnLeadCancel').addEventListener('click', closeLeadModal);
$('#leadModal').addEventListener('click', (e) => { if (e.target.id === 'leadModal') closeLeadModal(); });
$('#leadDesc').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) $('#btnLeadGenerate').click();
});
$('#btnLeadGenerate').addEventListener('click', async () => {
const text = $('#leadDesc').value.trim();
if (!text) { fieldError('#errLead', 'Describe what the lead should test.'); return; }
const btn = $('#btnLeadGenerate');
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Generating…';
try {
const { agent } = await api('/api/leads/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: text }),
});
await loadAgents(); // re-read agents_md/ so the new file appears in its category
state.selected.add(agent.id);
renderBoard();
closeLeadModal();
toast(`Generated and pinned: ${agent.title}`, 'ok');
} catch (e) {
// Fall back to the old behavior — fold the raw text into --focus context
// — so a missing/logged-out Claude CLI doesn't lose the operator's intent.
state.customLeads.push(text);
renderCustomLeads();
closeLeadModal();
toast(`Couldn't generate a skill (${e.message}) — kept as a focus hint instead.`, 'warn', 8000);
} finally {
btn.disabled = false;
btn.textContent = original;
}
});
$('#btnExpandAll').addEventListener('click', () => {
const anyCollapsed = $$('.cat-card').some((c) => c.classList.contains('collapsed'));
state.expandedCats = anyCollapsed ? new Set(state.categories.map((g) => g.category)) : new Set();
$$('.cat-card').forEach((c) => c.classList.toggle('collapsed', !anyCollapsed));
$('#btnExpandAll').textContent = anyCollapsed ? 'Collapse all' : 'Expand all';
});
$('#btnSelectAll').addEventListener('click', () => {
// Respects the current search/filter — selects only what's visible, so a
// filtered view ("sql") + Select all pins just those leads, not all 412.
const visible = state.search.trim() ? allAgents().filter((a) => (a.title + ' ' + a.name).toLowerCase().includes(state.search.trim().toLowerCase())) : allAgents();
visible.forEach((a) => state.selected.add(a.id));
renderBoard();
});
$('#btnClearAll').addEventListener('click', () => {
const visible = state.search.trim() ? allAgents().filter((a) => (a.title + ' ' + a.name).toLowerCase().includes(state.search.trim().toLowerCase())) : allAgents();
visible.forEach((a) => state.selected.delete(a.id));
renderBoard();
});
// ---------------------------------------------------------------------------
// providers / model (step 4)
// ---------------------------------------------------------------------------
async function loadProviders() {
state.providers = await api('/api/providers');
const sel = $('#fieldProvider');
sel.innerHTML = state.providers.map((p) => ``).join('');
sel.addEventListener('change', onProviderChange);
onProviderChange();
}
function onProviderChange() {
const p = state.providers.find((x) => x.key === $('#fieldProvider').value) || state.providers[0];
const modelSel = $('#fieldModelSelect');
modelSel.innerHTML = (p?.models || []).map((m) => ``).join('');
const subBtn = $('#authModeToggle button[data-mode="subscription"]');
const supportsSub = p?.kind === 'cli';
subBtn.disabled = !supportsSub;
subBtn.title = supportsSub ? '' : `${p?.label} has no local CLI subscription mode — API key only.`;
if (!supportsSub) setAuthMode('api');
updateAuthModeHelp();
}
function setAuthMode(mode) {
$$('#authModeToggle button').forEach((b) => b.classList.toggle('selected', b.dataset.mode === mode));
state.authMode = mode;
updateAuthModeHelp();
}
function updateAuthModeHelp() {
const p = state.providers.find((x) => x.key === $('#fieldProvider').value);
$('#authModeHelp').textContent = state.authMode === 'subscription'
? `Uses the locally logged-in ${p?.label || ''} CLI on this machine — no API key needed.`
: `Uses the API key set for ${p?.label || 'this provider'} in Auth & Keys.`;
}
$$('#authModeToggle button').forEach((b) => b.addEventListener('click', () => { if (!b.disabled) setAuthMode(b.dataset.mode); }));
state.authMode = 'api';
// ---------------------------------------------------------------------------
// review (step 5)
// ---------------------------------------------------------------------------
/// What the budget controls add up to, in the operator's words. "unlimited"
/// is spelled out rather than left blank, because the absence of a cap is the
/// thing worth confirming before launching.
function budgetSummary() {
const mode = $('#fieldBudget').value;
const limit = Number($('#fieldTokenLimit').value) || 0;
if (mode === 'unlimited' && !limit) return 'unlimited — full run, no cap';
const parts = [mode, $('#fieldOrder').value];
if (limit) parts.push(`${limit.toLocaleString()} tokens max`);
parts.push(`${$('#fieldSampleRoute').value}/route`);
return parts.join(' · ');
}
/// Gather the Scoping/Guardrails form into the object the server turns into a
/// scope YAML. A hard list is what makes it a boundary; without one the server
/// sends nothing and the run keeps its target+flags behaviour.
function collectScope() {
const lines = (id) => ($(`#${id}`)?.value || '').split(/[\n,;]+/).map((x) => x.trim()).filter(Boolean);
const hard = lines('scopeHard');
const scope = {
hard,
exclude: lines('scopeExclude'),
observeOnly: lines('scopeObserve'),
allowDestructive: $('#scopeDestructive')?.checked || false,
allowAccountCreation: $('#scopeAccounts') ? $('#scopeAccounts').checked : true,
maxAccounts: $('#scopeMaxAccounts')?.value ?? '',
rateLimit: $('#scopeRate')?.value ?? '',
forbidden: lines('scopeForbidden'),
notes: lines('scopeNotes'),
};
// Only meaningful when a boundary was actually drawn.
return hard.length ? scope : undefined;
}
function renderReview() {
const target = $('#fieldTarget').value.trim();
const repo = $('#fieldRepo').value.trim();
const provider = $('#fieldProvider').value;
const model = $('#fieldModelSelect').value;
const items = [
{ k: 'Engagement name', v: $('#fieldName').value.trim() || '(not set)' },
{ k: 'Mode', v: state.mode },
{ k: MODE_LABELS[state.mode].target, v: target || '(not set)', mono: true },
...(MODE_LABELS[state.mode].showRepo ? [{ k: 'Source repo', v: repo || '(not set)', mono: true }] : []),
{ k: 'Model', v: `${provider}:${model}` },
{ k: 'Auth mode', v: state.authMode === 'subscription' ? 'Subscription (local CLI)' : 'API key' },
{ k: 'Leads selected', v: `${state.selected.size} of ${allAgents().length}${state.selected.size === 0 ? ' — auto (recon-driven)' : ''}` },
{ k: 'Custom leads', v: String(state.customLeads.length) },
{ k: 'Votes / chain / recon', v: `${$('#fieldVotes').value} / ${$('#fieldChain').value} / ${$('#fieldRecon').value}` },
{ k: 'Budget', v: budgetSummary() },
{ k: 'Egress', v: state.authz.transport || 'direct' },
{ k: 'Out-of-band', v: state.authz.oobDomain ? `*.${state.authz.oobDomain}` : 'none — blind classes stay leads' },
{ k: 'Hard scope', v: (() => { const sc = collectScope(); return sc ? `${sc.hard.length} rule(s), ${sc.exclude.length} excluded, ${sc.rateLimit || '∞'}rpm${sc.allowDestructive ? ', destructive ON' : ''}` : 'target + authorized hosts only'; })() },
{ k: 'Intercept', v: $('#fieldIntercept').value === 'off' ? 'direct' : $('#fieldIntercept').value },
{ k: 'Sandbox', v: $('#fieldSandbox').value ? 'Kali container' : 'host' },
{ k: 'PoC re-validation', v: $('#fieldRevalidatePoc').checked ? 'on' : 'off' },
{ k: 'TypeSafe', v: $('#fieldTypesafe') ? $('#fieldTypesafe').value : 'auto' },
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
];
$('#reviewGrid').innerHTML = items.map((it) => `
${esc(it.k)}
${esc(it.v)}
`).join('');
}
// ---------------------------------------------------------------------------
// launch
// ---------------------------------------------------------------------------
$('#btnLaunch').addEventListener('click', startExploitation);
async function startExploitation() {
if (!validateStep(0)) { goToStep(0); return; }
const mode = state.mode;
const name = $('#fieldName').value.trim();
const target = $('#fieldTarget').value.trim();
const repo = $('#fieldRepo').value.trim();
const provider = $('#fieldProvider').value;
const model = $('#fieldModelSelect').value;
const focusParts = [$('#fieldFocus').value.trim(), ...state.customLeads].filter(Boolean);
const body = {
mode,
name,
target: mode === 'whitebox' ? undefined : target,
repo: mode === 'whitebox' ? target : (repo || undefined),
models: provider && model ? [`${provider}:${model}`] : [],
votes: Number($('#fieldVotes').value) || 3,
chainDepth: Number($('#fieldChain').value),
recon: Number($('#fieldRecon').value),
quick: $('#fieldQuick') ? $('#fieldQuick').checked : false,
research: $('#fieldResearch') ? $('#fieldResearch').checked : false,
subscription: state.authMode === 'subscription',
mcp: $('#fieldMcp').checked,
agents: [...state.selected],
focus: focusParts.join('; ') || undefined,
objective: $('#fieldObjective').value.trim() || undefined,
outOfScope: $('#fieldOutOfScope').value.trim() || undefined,
// Budget is opt-in: 'unlimited' sends nothing, so a run nobody budgeted is
// the same full run it was before this control existed.
budget: $('#fieldBudget').value,
intercept: $('#fieldIntercept').value,
sandbox: $('#fieldSandbox').value || undefined,
revalidatePoc: $('#fieldRevalidatePoc').checked,
typesafe: $('#fieldTypesafe') ? $('#fieldTypesafe').value : undefined,
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
tokenLimit: Number($('#fieldTokenLimit').value) || undefined,
order: $('#fieldOrder').value,
samplePerRoute: Number($('#fieldSampleRoute').value) || undefined,
auth: state.auth.header || undefined,
roles: state.auth.roles.length ? state.auth.roles : undefined,
creds: state.credsPath || undefined,
capability: state.authz.capability || undefined,
inScope: state.authz.inScope.split(/[,;\s]+/).filter(Boolean),
environment: state.authz.environment,
policyProfile: state.authz.policyProfile,
transport: state.authz.transport || undefined,
oobDomain: state.authz.oobDomain || undefined,
oobHttp: state.authz.oobHttp || undefined,
oobDns: state.authz.oobDns || undefined,
sms: state.authz.sms || undefined,
scope: collectScope(),
};
$('#btnLaunch').disabled = true;
$('#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, body.agents);
} catch (e) {
toast(`Failed to start: ${e.message}`, 'error', 9000);
} finally {
$('#btnLaunch').disabled = false;
$('#btnLaunch').textContent = 'Start Exploitation →';
}
}
// ---------------------------------------------------------------------------
// live run view
// ---------------------------------------------------------------------------
function bindRunTabs(scopeEl) {
$$('.run-tab', scopeEl).forEach((tab) => tab.addEventListener('click', () => {
$$('.run-tab', scopeEl).forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
$$('.run-tab-panel', scopeEl).forEach((p) => show(p, p.dataset.tabpanel === tab.dataset.tab));
}));
}
bindRunTabs($('#liveView'));
bindRunTabs($('#detailView'));
const ACTIVE_JOB_KEY = 'ns-active-job';
function attachLiveJob(id, target, name, pinnedAgents) {
if (state.currentJob?.es) state.currentJob.es.close();
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,
agentStatus: {},
};
localStorage.setItem(ACTIVE_JOB_KEY, id);
if ($('#agentGrid')) $('#agentGrid').innerHTML = '';
if ($('#agentPanel')) $('#agentPanel').hidden = true;
show($('#wizardView'), false);
show($('#detailView'), false);
show($('#dashView'), false);
show($('#liveView'), true);
$('#liveTarget').textContent = name || target || '—';
$('#liveTargetSub').textContent = name ? target : '';
$('#livePhase').textContent = 'starting';
$('#phaseDot').style.background = '';
$('#phaseDot').classList.remove('static');
$('#logList').innerHTML = '';
state.tables.live.sev = null;
state.tables.live.query = '';
$('#liveFindingSearch').value = '';
renderFindings('live');
$('#progressBar').classList.add('indeterminate');
$('#progressFill').style.width = '0%';
$('#progressLabel').textContent = '0 / ? agents';
updatePinnedLine();
show($('#btnOpenReport'), false);
show($('#sendPromptRow'), false);
show($('#sendPromptHelp'), false);
termSyncTargets();
const es = new EventSource(`/api/exploit/${id}/events`);
state.currentJob.es = es;
es.addEventListener('log', (e) => appendLog(JSON.parse(e.data).line));
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
es.addEventListener('agent', (e) => { const a = JSON.parse(e.data); if (state.currentJob) { (state.currentJob.agentStatus ||= {})[a.name] = { status: a.status, found: a.found || 0 }; renderAgentPanel(state.currentJob.agentStatus); } });
es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data)));
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) {
const div = document.createElement('div');
div.className = 'log-line';
div.textContent = line;
const list = $('#logList');
list.appendChild(div);
list.scrollTop = list.scrollHeight;
// When the terminal is attached to this engagement it is the same stream —
// mirror it there so the operator types and reads in one place.
if (term.mode === 'job' && term.xterm) termWrite(line + '\r\n');
}
// Only run/whitebox/greybox jobs are REPL-backed (interactive: true) — the
// session keeps reading stdin while the engagement streams, so this is a
// real command line into the SAME process, not a fire-and-forget note.
$('#sendPromptInput').addEventListener('keydown', async (e) => {
if (e.key !== 'Enter' || !state.currentJob) return;
const line = e.target.value;
if (!line.trim()) return;
e.target.value = '';
const div = document.createElement('div');
div.className = 'log-line log-echo';
div.textContent = `❭ ${line}`;
const list = $('#logList');
list.appendChild(div);
list.scrollTop = list.scrollHeight;
try {
await api(`/api/exploit/${state.currentJob.id}/input`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }),
});
} catch (err) {
appendLog(`[web] couldn't send: ${err.message}`);
}
});
// What the wire said about a finding's evidence (see the harness's wire pass):
// the harness re-sent the agent's requests itself and/or matched them against
// recorded traffic. A contradiction means the finding cannot stand as proven.
function wireState(f) {
const ev = f.evidence_data;
if (!ev) return { key: 'none', rank: 3, label: 'no evidence', title: 'No structured evidence was recorded — nothing to check on the wire.' };
const w = ev.wire;
const fromWire = (x) => x && String(x.provenance || '').startsWith('wire:');
if (w && (w.contradicted || []).length) {
return { key: 'contradicted', rank: 0, label: 'contradicted', title: w.contradicted.join('\n') };
}
const backed = w ? (w.reproduced || 0) + (w.corroborated || 0) + (w.harness_observed || 0) : 0;
if (backed > 0 || fromWire(ev.attack)) {
const parts = w ? [`${w.reproduced || 0} re-observed by the harness`, `${w.corroborated || 0} matched recorded traffic`, `${w.harness_observed || 0} harness-made`] : ['harness-observed'];
return { key: 'verified', rank: 1, label: 'wire-verified', title: parts.join(' · ') };
}
if (w) return { key: 'unverified', rank: 2, label: 'unverifiable', title: `${w.unverified || 0} exchange(s) could not be re-observed (mutating verb, scope, missing credentials or network).${(w.absent_from_capture || []).length ? `\nAbsent from the capture: ${w.absent_from_capture.join(', ')}` : ''}` };
return { key: 'pending', rank: 2, label: 'pending', title: 'Not checked yet — the wire pass runs before final validation.' };
}
function reviewStatus(f) {
return f.review_status || (f.validated ? 'confirmed' : 'candidate');
}
function findingRow(f, idx) {
const w = wireState(f);
const st = reviewStatus(f);
return `
${esc(f.severity)}
${esc(f.title)}
${esc(f.endpoint)}
${esc(f.cwe)}
${esc(st)}
${esc(w.label)}
${esc(f.agent)}
${f.confidence ? f.confidence.toFixed(2) : '—'}
`;
}
// ---------------------------------------------------------------------------
// findings table — sort / filter / severity summary
//
// The table used to render in whatever order the harness emitted findings,
// which puts a LOW above a CRITICAL and makes a 27-row result unreadable.
// Sorting defaults to severity so the worst finding is the first thing on
// screen, and the summary doubles as a one-click severity filter.
// ---------------------------------------------------------------------------
const TABLES = {
live: { tbody: '#liveFindingsTable tbody', table: '#liveFindingsTable', empty: '#liveFindingsEmpty', count: '#liveFindingsCount', summary: '#liveSevSummary', search: '#liveFindingSearch' },
detail: { tbody: '#detailFindingsTable tbody', table: '#detailFindingsTable', empty: '#detailFindingsEmpty', count: '#detailFindingsCount', summary: '#detailSevSummary', search: '#detailFindingSearch', kpis: '#detailKpis' },
};
const SEV_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
function tableFindings(which) {
return which === 'live' ? (state.currentJob?.findings || []) : (state.detailFindings || []);
}
function renderFindings(which) {
const cfg = TABLES[which];
const t = state.tables[which];
const all = tableFindings(which);
const q = t.query.trim().toLowerCase();
// Carry the original index: the row click handler looks the finding up by
// position in the unsorted array.
let rows = all.map((f, idx) => ({ f, idx }));
if (t.sev) rows = rows.filter(({ f }) => SEV_ORDER[sevRank(f.severity)] === t.sev);
if (q) rows = rows.filter(({ f }) => `${f.title} ${f.endpoint} ${f.cwe} ${f.agent} ${f.severity}`.toLowerCase().includes(q));
const key = t.sort;
rows.sort((a, b) => {
let cmp;
if (key === 'severity') cmp = sevRank(a.f.severity) - sevRank(b.f.severity) || (b.f.confidence || 0) - (a.f.confidence || 0);
else if (key === 'confidence') cmp = (b.f.confidence || 0) - (a.f.confidence || 0);
else if (key === 'wire') cmp = wireState(a.f).rank - wireState(b.f).rank;
else if (key === 'review_status') cmp = reviewStatus(a.f).localeCompare(reviewStatus(b.f));
else cmp = String(a.f[key] || '').localeCompare(String(b.f[key] || ''));
return cmp * t.dir;
});
t.visible = rows.map(({ f }) => f);
$(cfg.tbody).innerHTML = rows.map(({ f, idx }) => findingRow(f, idx)).join('');
if (cfg.kpis) renderKpis(cfg.kpis, all);
$(cfg.count).textContent = all.length;
show($(cfg.empty), rows.length === 0);
$(cfg.empty).textContent = all.length && !rows.length
? 'No finding matches this filter.'
: (which === 'live' ? 'No validated findings yet.' : 'No validated findings.');
const counts = {};
for (const f of all) { const s = SEV_ORDER[sevRank(f.severity)]; counts[s] = (counts[s] || 0) + 1; }
$(cfg.summary).innerHTML = SEV_ORDER.filter((s) => counts[s]).map((s) => `
`).join('') || 'No findings yet.';
$$(`${cfg.summary} .sev-pill`).forEach((btn) => btn.addEventListener('click', () => {
t.sev = t.sev === btn.dataset.sev ? null : btn.dataset.sev;
renderFindings(which);
}));
$$(`${cfg.table} thead th`).forEach((th) => th.classList.toggle('sorted', th.dataset.sort === key));
$$(`${cfg.table} thead th`).forEach((th) => th.dataset.dir = th.dataset.sort === key ? (t.dir > 0 ? 'asc' : 'desc') : '');
}
for (const [which, cfg] of Object.entries(TABLES)) {
$(cfg.search).addEventListener('input', (e) => { state.tables[which].query = e.target.value; renderFindings(which); });
$$(`${cfg.table} thead th[data-sort]`).forEach((th) => {
th.addEventListener('click', () => {
const t = state.tables[which];
if (t.sort === th.dataset.sort) t.dir *= -1; else { t.sort = th.dataset.sort; t.dir = 1; }
renderFindings(which);
});
});
}
// 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) {
state.currentJob.findings.push(f);
renderFindings('live');
}
// Live "what's being tested" grid: one chip per agent, coloured by status.
function renderAgentPanel(statusMap) {
const grid = $('#agentGrid');
const panel = $('#agentPanel');
if (!grid || !panel) return;
const entries = Object.entries(statusMap || {});
if (!entries.length) { panel.hidden = true; return; }
panel.hidden = false;
// Order: running first, then found, then pending, then done, then failed.
const rank = (s, found) => s === 'running' ? 0 : (s === 'done' && found > 0) ? 1 : s === 'pending' ? 2 : s === 'done' ? 3 : 4;
entries.sort((a, b) => rank(a[1].status, a[1].found) - rank(b[1].status, b[1].found) || a[0].localeCompare(b[0]));
const cls = (s, found) => s === 'running' ? 'running' : s === 'failed' ? 'failed' : (s === 'done' && found > 0) ? 'found' : s === 'done' ? 'done' : 'pending';
grid.innerHTML = entries.map(([name, st]) =>
`${st.status === 'running' ? '' : ''}${esc(name)}${st.found ? `${st.found}` : ''}`).join('');
const done = entries.filter(([, s]) => s.status === 'done' || s.status === 'failed').length;
const run = entries.filter(([, s]) => s.status === 'running').length;
$('#agentPanel .agent-panel-head > span').textContent = `What's being tested — ${run} running · ${done}/${entries.length} done`;
}
function applySnapshot(snap) {
if (snap.agentStatus) {
if (state.currentJob) state.currentJob.agentStatus = snap.agentStatus;
renderAgentPanel(snap.agentStatus);
}
$('#livePhase').textContent = snap.phase;
state.currentJob.runId = snap.runId;
state.currentJob.interactive = !!snap.interactive;
show($('#sendPromptRow'), snap.interactive && !snap.done);
show($('#sendPromptHelp'), snap.interactive && !snap.done);
if (snap.pinnedAgents?.length && !state.currentJob.pinnedAgents.length) {
state.currentJob.pinnedAgents = snap.pinnedAgents;
updatePinnedLine();
}
if (state.currentJob) state.currentJob.phase = snap.phase;
$('#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`;
show($('#btnOpenReport'), true);
}
const paused = (snap.phase || '').startsWith('paused');
$('#btnPauseRun').textContent = paused ? '▶ Continue' : '⏸ Pause';
$('#btnPauseRun').classList.toggle('btn-warn', paused);
$('#btnPauseRun').disabled = !!snap.done || !snap.interactive;
$('#btnReportNow').disabled = !snap.interactive;
$('#btnDownloadLog').href = `/api/exploit/${snap.id}/log`;
if (snap.done) $('#phaseDot').classList.add('static');
}
$('#btnStopRun').addEventListener('click', async () => {
if (!state.currentJob) return;
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
});
// Pause is a toggle against the run's own phase, so the button always says
// what pressing it will do rather than what the run currently is.
$('#btnPauseRun').addEventListener('click', async () => {
if (!state.currentJob) return;
const paused = (state.currentJob.phase || '').startsWith('paused');
const verb = paused ? 'continue' : 'pause';
try {
await api(`/api/exploit/${state.currentJob.id}/${verb}`, { method: 'POST' });
toast(paused ? 'Resuming the run.' : 'Pausing — in-flight agents finish first, findings are kept.', 'ok');
} catch (e) {
toast(e.message, 'error', 8000);
}
});
// Report from where it stopped: the REPL's /report writes from the evidence on
// disk, so a run that is paused, stalled or simply long can be read now.
$('#btnReportNow').addEventListener('click', async () => {
if (!state.currentJob) return;
try {
await api(`/api/exploit/${state.currentJob.id}/report`, { method: 'POST' });
toast('Building a report from what has been found so far — it appears above when written.', 'ok', 7000);
} catch (e) {
toast(e.message, 'error', 8000);
}
});
function leaveLiveJob() {
localStorage.removeItem(ACTIVE_JOB_KEY);
clearInterval(state.currentJob?.pocPoll);
state.currentJob?.es?.close();
state.currentJob = null;
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;
}
});
$('#btnDeleteRun').addEventListener('click', async () => {
const id = state.currentDetailId;
if (!id) return;
const label = $('#detailTarget').textContent || id;
if (!confirm(`Delete this session permanently?\n\n${label}\n${id}\n\nThis removes the findings, evidence, PoCs and every report artifact for this run. It cannot be undone.`)) return;
const btn = $('#btnDeleteRun');
btn.disabled = true;
try {
await api(`/api/runs/${encodeURIComponent(id)}`, { method: 'DELETE' });
toast('Session deleted.', 'ok', 5000);
clearInterval(state.detailPoll);
state.currentDetailId = null;
state.detailLoadedId = null;
show($('#detailView'), false); show($('#dashView'), false); show($('#wizardView'), true);
await refreshRuns();
} catch (e) {
toast(`Couldn't delete the session: ${e.message}`, 'error', 9000);
} finally {
btn.disabled = false;
}
});
$('#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); });
// ---------------------------------------------------------------------------
// 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('');
// The report footer ("Identified and validated by NeuroSploit...") gets
// baked into impact/business_impact by the reporter — strip it from every
// field so it doesn't repeat per-section, and surface it once at the
// bottom of the modal instead.
const ATTRIBUTION_RE = /Identified and validated by NeuroSploit[\s\S]*?Red Team Leaders\.?/i;
let attributed = false;
const clean = (text) => {
if (!text) return '';
const stripped = text.replace(ATTRIBUTION_RE, () => { attributed = true; return ''; });
return stripped.split(/\n\n+/).map((p) => p.trim()).filter(Boolean).join('\n\n');
};
// Technical evidence (endpoint/payload/curl) reads as code; prose
// (description/impact/remediation) reads as a paragraph, not a code block.
const codeBlock = (label, text) => text
? `
` : '';
const impactText = clean(f.impact);
const bizText = clean(f.business_impact);
const impactCombined = bizText && bizText !== impactText
? [impactText, bizText].filter(Boolean).join('\n\n') : impactText;
// A reader works through a finding in a fixed order — where is it, what does
// it mean, how do I fix it, how do I see it myself. The old layout led with
// an evidence dump, which answers the last question first and the first three
// not at all.
$('#fmSection-evidence').innerHTML =
proseBlock('Where the problem is', locationLine(f)) +
proseBlock('What it means', impactCombined || clean(f.evidence)) +
proseBlock('How to fix it', clean(f.remediation));
$('#fmSection-impact').innerHTML = pocBlock(f, pocs, runId);
$('#fmSection-remediation').innerHTML = codeBlock('Technical evidence', technicalEvidence(f));
$('#fmSection-chains').innerHTML =
((f.chains_from || []).length ? `
Chains from: ${esc(f.chains_from.join(', '))}
` : '') +
(attributed ? '
Identified and validated by NeuroSploit (multi-model adversarial validation) — full methodology in the generated report.
' : '');
// 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.
// Scripts this finding cites are rendered inside the PoC block above; this
// list is the run's remaining scripts, so nothing written is hidden.
const citedIn = `${f.evidence || ''} ${f.payload || ''} ${(f.repro_steps || []).join(' ')}`;
const list = (pocs || []).filter((p) => !citedIn.includes(p));
const pocRoot = $('#fmPocList');
if (!list.length) {
pocRoot.textContent = 'No other scripts from this run.';
} else {
pocRoot.innerHTML = list.map((name) => `
`).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); });
/// Exactly where the problem is: parameter/field/flow, not just the URL.
function locationLine(f) {
const loc = (f.location || '').trim();
const ep = (f.endpoint || '').trim();
if (!loc && !ep) return '(location not recorded)';
if (!loc) return ep;
if (!ep || loc.includes(ep)) return loc;
return `${ep} — ${loc}`;
}
function isSecretHeader(k) {
const n = k.toLowerCase();
return n === 'authorization' || n === 'cookie' || n === 'x-api-key' || n.includes('token') || n.includes('secret');
}
/// A pasteable curl for the recorded request. Credentials are redacted: a
/// finding gets shared, and a live session cookie in a document is a new bug.
function curlCommand(f) {
const a = f.evidence_data?.attack;
if (a) {
let cmd = 'curl -i -s';
const m = (a.method || 'GET').toUpperCase();
if (m !== 'GET') cmd += ` -X ${m}`;
for (const [k, v] of Object.entries(a.request_headers || {})) {
cmd += ` \\\n -H '${k}: ${isSecretHeader(k) ? '' : v}'`;
}
if (f.payload && m !== 'GET') cmd += ` \\\n --data-raw '${String(f.payload).replace(/'/g, "'\\''")}'`;
cmd += ` \\\n '${a.url}'`;
return cmd;
}
return f.endpoint ? `curl -i -s '${f.endpoint}'` : '';
}
/// Ordered steps: what the agent recorded, or a minimal derived sequence.
function reproSteps(f) {
if (Array.isArray(f.repro_steps) && f.repro_steps.length) return f.repro_steps;
const steps = [];
const ev = f.evidence_data;
if (ev?.identity_a && ev?.identity_b) {
steps.push(`As ${ev.identity_a.identity || 'the owner'}:\ncurl -i -s '${ev.identity_a.url}'`);
steps.push(`As ${ev.identity_b.identity || 'the other identity'}, request the SAME resource:\ncurl -i -s '${ev.identity_b.url}'`);
steps.push('Compare the two bodies — the second returning the first\u2019s data is the finding.');
return steps;
}
if (ev?.baseline) steps.push(`Baseline — the same resource without the payload:\ncurl -i -s '${ev.baseline.url}'`);
const curl = curlCommand(f);
if (curl) steps.push(`Send the request carrying the payload:\n${curl}`);
if (f.payload) steps.push(`Payload used:\n${String(f.payload).trim()}`);
return steps.length ? steps : ['No reproduction steps were recorded for this finding.'];
}
/// The measured difference plus the raw exchanges — what turns "it returned a
/// 500" into something a reviewer can check.
function technicalEvidence(f) {
const ev = f.evidence_data;
let out = '';
const ex = (label, x) => {
if (!x) return '';
let s = `${label}\n ${(x.method || 'GET')} ${x.url} → ${x.status}\n`;
if (x.identity) s += ` identity: ${x.identity}\n`;
for (const k of ['location', 'set-cookie', 'content-type', 'access-control-allow-origin', 'access-control-allow-credentials', 'x-frame-options', 'content-security-policy', 'retry-after']) {
const v = (x.headers || {})[k];
if (v) s += ` ${k}: ${String(v).slice(0, 200)}\n`;
}
if (x.body?.trim()) s += ` body (${x.body.length} bytes, excerpt):\n${x.body.trim().slice(0, 1200).split('\n').map((l) => ' ' + l).join('\n')}\n`;
return s + '\n';
};
if (ev?.baseline && ev?.attack) {
const b = ev.baseline, a = ev.attack;
const ratio = b.body?.length ? (a.body.length / b.body.length) : 0;
out += `MEASURED DIFFERENCE\n baseline : ${b.status} · ${b.body?.length ?? 0} bytes · ${b.elapsed_ms ?? 0} ms\n`
+ ` attack : ${a.status} · ${a.body?.length ?? 0} bytes · ${a.elapsed_ms ?? 0} ms\n`
+ ` delta : status ${b.status} → ${a.status}, body ${((ratio - 1) * 100).toFixed(0)}%\n`;
if (ev.repeats?.length) out += ` repeats : ${ev.repeats.length} recorded\n`;
out += '\n';
}
if (ev?.marker) {
out += `CONTROLLED MARKER\n ${ev.marker} — observed: ${ev.marker_observed ? 'yes' : 'no'}`
+ `${ev.browser_executed ? ' · executed in a real browser' : ''}${ev.callback_received ? ' · out-of-band callback' : ''}\n\n`;
}
out += ex('BASELINE', ev?.baseline) + ex('ATTACK', ev?.attack) + ex('AS OWNER', ev?.identity_a) + ex('AS OTHER IDENTITY', ev?.identity_b);
if (f.evidence?.trim()) out += `AGENT-RECORDED EVIDENCE\n${f.evidence.trim()}\n`;
return out.trim();
}
/// The proof: numbered steps, then the payload, with any script offered as an
/// extra artifact rather than as the proof itself.
function pocBlock(f, pocs, runId) {
const steps = reproSteps(f).map((s) => `
`;
wrap.querySelector('.sb-folder-head').addEventListener('click', () => {
const nowCollapsed = wrap.classList.toggle('collapsed');
setFolderOpen(f.key, !nowCollapsed);
});
const items = wrap.querySelector('.sb-items');
for (const r of f.items) items.appendChild(runButton(r));
root.appendChild(wrap);
}
}
// Resume an interrupted run from the sidebar: if a persisted job exists for it,
// relaunch it live; otherwise just open its detail (partial findings on disk).
async function resumeFromSidebar(run) {
let jobs = [];
try { jobs = await api('/api/exploit'); } catch { /* fall through to detail */ }
const job = jobs.find((j) => (j.interrupted && j.resumable) && (j.runId === run.id || j.id === run.id || j.id === run.jobId));
if (job) {
try {
await api(`/api/exploit/${job.id}/resume`, { method: 'POST' });
localStorage.setItem(ACTIVE_JOB_KEY, job.id);
attachLiveJob(job.id, run.target, run.name, job.pinnedAgents || []);
await refreshRuns();
return;
} catch (e) {
toast(`Couldn't resume: ${e.message}`, 'error', 8000);
}
}
// No resumable job — show what's on disk.
show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true);
loadDetail(run.id);
renderSidebar();
}
function openRun(run) {
state.currentDetailId = run.id;
if (run.state === 'running' && state.currentJob && run.id === state.currentJob.runId) {
show($('#wizardView'), false); show($('#detailView'), false); show($('#dashView'), false); show($('#liveView'), true);
renderSidebar();
return;
}
// A running job we're not already attached to (e.g. opened in another tab, or
// just started): reconnect its live stream instead of showing a static detail.
if (run.state === 'running' && (run.jobId || run.id)) {
const jid = run.jobId || run.id;
localStorage.setItem(ACTIVE_JOB_KEY, jid);
attachLiveJob(jid, run.target, run.name, []);
renderSidebar();
return;
}
if (run.state === 'interrupted') {
// Offer to resume where it left off; falls back to the static detail view.
resumeFromSidebar(run);
return;
}
show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true);
loadDetail(run.id);
renderSidebar();
}
async function loadDetail(id) {
clearInterval(state.detailPoll);
// A poll for the previously opened run may still be in flight; when it
// lands it must not repaint that run over the one the operator opened.
const token = (state.detailToken = (state.detailToken || 0) + 1);
if (state.detailLoadedId !== id) {
// A filter left over from the previous run would silently hide findings
// in the one just opened.
state.tables.detail.sev = null;
state.tables.detail.query = '';
$('#detailFindingSearch').value = '';
state.detailLoadedId = id;
}
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
if (token !== state.detailToken) return;
const target = detail.status?.target || detail.meta?.target || id;
$('#detailTarget').textContent = detail.name || target;
$('#detailTargetSub').textContent = detail.name ? target : '';
$('#detailState').textContent = detail.status?.state || 'unknown';
state.detailFindings = detail.findings;
state.detailPocs = detail.pocs || [];
renderFindings('detail');
// A past run's identity: when it ran, how many agents, what asset the recon
// decided it was. Previously the page showed only target + state, so two
// runs of the same target were indistinguishable.
const facts = [
detail.status?.ts ? `${new Date(detail.status.ts * 1000).toLocaleString()} · ${timeAgo(detail.status.ts)}` : '',
detail.status?.agents_ran ? `${detail.status.agents_ran} agents ran` : '',
detail.meta?.asset || '',
detail.pocs?.length ? `${detail.pocs.length} PoC script(s)` : '',
id,
].filter(Boolean);
$('#detailFacts').innerHTML = facts.map((f) => `${esc(f)}`).join('');
const reportLink = $('#detailOpenReport');
if (detail.assets.includes('report.html')) {
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
show(reportLink, true);
} else show(reportLink, false);
// The PDF is produced by the harness (Typst) when that binary is present, so
// it is offered only when it actually exists — a dead download button is
// worse than none.
// The audit trail travels with the run's evidence; offering it here is what
// makes "show me what the tool did" a link rather than a support request.
const auditLink = $('#detailOpenAudit');
if (detail.assets.includes('audit.jsonl')) {
auditLink.href = `/api/runs/${encodeURIComponent(id)}/asset/audit.jsonl`;
show(auditLink, true);
} else show(auditLink, false);
const pdfLink = $('#detailOpenPdf');
if (detail.assets.includes('report.pdf')) {
pdfLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.pdf`;
pdfLink.setAttribute('download', `${id}.pdf`);
show(pdfLink, true);
} else show(pdfLink, false);
afterDetailLoaded(id, detail);
if (token !== state.detailToken) return;
if (detail.status?.state === 'running') state.detailPoll = setInterval(() => loadDetail(id), 4000);
}
// ---------------------------------------------------------------------------
// Auth & Keys modal
// ---------------------------------------------------------------------------
function openAuthModal() {
show($('#authModal'), true);
renderRoleList();
$('#credsPath').value = state.credsPath;
refreshKeyStatus();
}
['#btnOpenAuth', '#btnOpenAuth2', '#btnOpenAuth3'].forEach((sel) => $(sel)?.addEventListener('click', openAuthModal));
$('#btnCloseAuth').addEventListener('click', () => show($('#authModal'), false));
$('#authModal').addEventListener('click', (e) => { if (e.target.id === 'authModal') show($('#authModal'), false); });
$$('.modal-tab').forEach((tab) => tab.addEventListener('click', () => {
$$('.modal-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
$$('.modal-panel').forEach((p) => show(p, p.dataset.mpanel === tab.dataset.mtab));
}));
$('#authHeader').addEventListener('input', (e) => { state.auth.header = e.target.value.trim(); });
$('#authHeader').value = state.auth.header;
$('#credsPath').addEventListener('input', (e) => { state.credsPath = e.target.value.trim(); });
$('#capToken').addEventListener('input', (e) => {
state.authz.capability = e.target.value.trim();
// Decode the claims for display only. This is NOT verification — the
// signature is checked by the harness, which holds the key; showing a
// "valid" badge here would be the browser vouching for something it cannot
// check.
const el = $('#capStatus');
const t = state.authz.capability;
if (!t) { el.textContent = ''; el.className = 'field-status'; return; }
try {
const body = t.replace(/^ns-cap\.v1\./, '').split('.')[0];
const claims = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/')));
const left = claims.expires_at ? Math.round((claims.expires_at - Date.now() / 1000) / 3600) : null;
el.className = 'field-status ' + (left !== null && left <= 0 ? 'bad' : 'ok');
el.textContent = `claims (unverified here — the harness checks the signature): ${claims.issuer} → ${claims.subject} · ${(claims.scope || []).join(', ')} · ${claims.environment} · max ${claims.max_action}` +
(left === null ? '' : left <= 0 ? ' · EXPIRED' : ` · ${left}h left`);
} catch {
el.className = 'field-status bad';
el.textContent = 'not a readable ns-cap.v1 token';
}
});
$('#inScope').addEventListener('input', (e) => { state.authz.inScope = e.target.value; });
$('#envSelect').addEventListener('change', (e) => { state.authz.environment = e.target.value; });
$('#policySelect').addEventListener('change', (e) => { state.authz.policyProfile = e.target.value; });
// Egress and OOB live with authorization, not with run settings: they decide
// WHICH network is being tested, which is an authorization question.
for (const [id, key] of [['transportSpec', 'transport'], ['oobDomain', 'oobDomain'], ['oobHttp', 'oobHttp'], ['oobDns', 'oobDns'], ['smsSpec', 'sms']]) {
$(`#${id}`).addEventListener('input', (e) => { state.authz[key] = e.target.value.trim(); });
}
function renderRoleList() {
const root = $('#roleList');
root.innerHTML = state.auth.roles.map((r, i) => `
`;
}).join('');
$$('[data-save]', root).forEach((btn) => btn.addEventListener('click', async () => {
const provider = btn.dataset.save;
const input = root.querySelector(`input[data-provider="${provider}"]`);
const key = input.value.trim();
if (!key) return;
await api('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, key }) });
input.value = '';
refreshKeyStatus();
}));
}
// ---------------------------------------------------------------------------
// Terminal dock — the real CLI harness, rendered by xterm.js
//
// The old drawer was a
holding text: the harness's ANSI (colour, the
// box-drawn /status panel, its spinners) arrived stripped, long lines rewrapped
// mid-glyph, and it floated over the wizard's primary button. This is the same
// arrangement the Unistrike console uses — a terminal emulator fed the child
// process's raw stdout — docked so it takes vertical space instead of covering
// the page.
//
// One difference matters and shapes the code below: there is no PTY here (the
// server has no native deps), so the child sees a pipe and never echoes what
// is typed. The line editor — echo, cursor, history, Tab completion — is
// therefore local, and only completed lines cross the wire.
// ---------------------------------------------------------------------------
const SLASH_COMMANDS = [
'/agents', '/attach', '/auth', '/burp', '/chain', '/clear', '/config', '/context', '/continue',
'/creds', '/exclude', '/exit', '/expand', '/feed', '/finding', '/findings', '/focus', '/full',
'/go', '/goal', '/help', '/history', '/idle', '/instructions', '/integrations', '/key', '/log',
'/mcp', '/model', '/models', '/objective', '/offline', '/onboard', '/only', '/proxy', '/providers',
'/quit', '/recon', '/repo', '/report', '/results', '/resume', '/retest', '/revalidate', '/run',
'/runs', '/scope-out', '/show', '/status', '/stop', '/subscription', '/target', '/temp-email',
'/theme', '/timeout', '/useragent', '/validate', '/votes',
];
const term = {
xterm: null, fit: null, ro: null,
mode: 'session', // 'session' = standalone REPL · 'job' = the live engagement
replId: null, es: null,
line: '', cursor: 0,
history: [], hIdx: null, stash: '',
booted: false,
};
function termColors() {
// Read the theme tokens rather than hardcoding: the console has a light mode,
// and a terminal with its own fixed palette looks pasted in.
const css = getComputedStyle(document.documentElement);
const v = (n, fb) => (css.getPropertyValue(n) || '').trim() || fb;
return {
background: v('--term-bg', '#0f0e10'),
foreground: v('--term-fg', '#d8d5cf'),
cursor: v('--accent', '#e08a3e'),
selectionBackground: v('--accent-soft', '#3a2a16'),
};
}
function termStatus(stateName, text) {
$('#termDot').dataset.state = stateName;
$('#termStatus').textContent = text;
}
function termAlert(msg, kind = 'warn') {
const el = $('#termAlert');
el.textContent = msg || '';
el.dataset.kind = kind;
show(el, !!msg);
}
function termFit() {
if (!term.fit || !term.xterm || $('#termDock').hidden) return;
try { term.fit.fit(); } catch { /* container measured 0 — nothing to fit to */ }
}
function termEnsure() {
if (term.xterm) return true;
if (typeof Terminal === 'undefined') {
termAlert("xterm.js didn't load — the terminal can't run. Check /vendor/xterm.js is being served.", 'error');
termStatus('error', 'unavailable');
return false;
}
term.xterm = new Terminal({
fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono').trim() || 'monospace',
fontSize: 12,
cursorBlink: true,
convertEol: true, // the harness writes bare \n; without this every line stair-steps
scrollback: 8000,
theme: termColors(),
});
if (window.FitAddon?.FitAddon) {
term.fit = new window.FitAddon.FitAddon();
term.xterm.loadAddon(term.fit);
}
term.xterm.open($('#termHost'));
term.xterm.onData(termOnData);
if (window.ResizeObserver) {
// The dock is user-resizable and the sidebar collapses — both change the
// terminal's box without firing a window resize.
term.ro = new ResizeObserver(() => termFit());
term.ro.observe($('#termHost'));
}
window.addEventListener('resize', termFit);
return true;
}
function termWrite(s) { term.xterm?.write(s); }
function termNote(s) { termWrite(`\x1b[2m${s}\x1b[0m\r\n`); }
/* ---- local line editing ---- */
function termSetLine(s) {
if (!term.xterm) return;
if (term.cursor > 0) termWrite(`\x1b[${term.cursor}D`);
termWrite('\x1b[K' + s);
term.line = s;
term.cursor = s.length;
}
function termInsert(s) {
const rest = term.line.slice(term.cursor);
term.line = term.line.slice(0, term.cursor) + s + rest;
term.cursor += s.length;
termWrite(s + rest);
if (rest.length) termWrite(`\x1b[${rest.length}D`);
}
function termBackspace() {
if (term.cursor === 0) return;
term.line = term.line.slice(0, term.cursor - 1) + term.line.slice(term.cursor);
term.cursor--;
const rest = term.line.slice(term.cursor);
termWrite('\b' + rest + ' ' + `\x1b[${rest.length + 1}D`);
}
function termComplete() {
const word = term.line.slice(0, term.cursor);
if (!word.startsWith('/') || word.includes(' ')) return;
const hits = SLASH_COMMANDS.filter((c) => c.startsWith(word));
if (!hits.length) return;
if (hits.length === 1) { termInsert(hits[0].slice(word.length) + ' '); return; }
// Complete as far as the candidates agree, then show what's left to choose.
let common = hits[0];
for (const h of hits) { while (!h.startsWith(common)) common = common.slice(0, -1); }
const line = term.line;
termWrite('\r\n' + hits.join(' ') + '\r\n');
term.line = ''; term.cursor = 0;
termInsert(common.length > word.length ? common + line.slice(word.length) : line);
}
async function termSubmit() {
const line = term.line;
termWrite('\r\n');
term.line = ''; term.cursor = 0; term.hIdx = null;
if (line.trim()) {
term.history.push(line);
if (term.history.length > 200) term.history.shift();
}
try {
if (term.mode === 'job') {
const job = state.currentJob;
if (!job) { termNote('no live engagement attached — switch the target back to the standalone session.'); return; }
await api(`/api/exploit/${job.id}/input`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }),
});
} else {
if (!term.replId) await termConnect();
await api(`/api/repl/${term.replId}/input`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: line + '\n' }),
});
}
} catch (e) {
termWrite(`\x1b[31m[web] couldn't send: ${e.message}\x1b[0m\r\n`);
}
}
async function termInterrupt() {
termWrite('^C\r\n');
term.line = ''; term.cursor = 0;
try {
if (term.mode === 'job' && state.currentJob) {
// The engagement's own graceful stop — a raw SIGINT would kill the run
// before it validates and reports what it already found.
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
termNote('sent /stop to the engagement (validate what is found so far, then report).');
} else if (term.replId) {
await api(`/api/repl/${term.replId}/input`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: '\x03' }),
});
}
} catch (e) { termNote(`interrupt failed: ${e.message}`); }
}
function termOnData(data) {
// A paste arrives as one chunk and may carry newlines: split it so each line
// is submitted, instead of shipping embedded \n the REPL would mis-read.
if (data.length > 1 && !data.startsWith('\x1b') && /[\r\n]/.test(data)) {
const parts = data.split(/\r?\n/);
parts.forEach((part, i) => {
if (part) termInsert(part);
if (i < parts.length - 1) termSubmit();
});
return;
}
switch (data) {
case '\r': case '\n': return void termSubmit();
case '\x7f': case '\b': return termBackspace();
case '\x03': return void termInterrupt();
case '\x0c': return termClear(); // Ctrl-L
case '\x15': return termSetLine(''); // Ctrl-U
case '\x0b': { // Ctrl-K — kill to end
const keep = term.line.slice(0, term.cursor);
const killed = term.line.length - keep.length;
term.line = keep;
if (killed) termWrite('\x1b[K');
return;
}
case '\x01': case '\x1b[H': // Ctrl-A / Home
if (term.cursor) { termWrite(`\x1b[${term.cursor}D`); term.cursor = 0; }
return;
case '\x05': case '\x1b[F': { // Ctrl-E / End
const move = term.line.length - term.cursor;
if (move) { termWrite(`\x1b[${move}C`); term.cursor = term.line.length; }
return;
}
case '\t': return termComplete();
case '\x1b[D': if (term.cursor > 0) { term.cursor--; termWrite('\x1b[D'); } return;
case '\x1b[C': if (term.cursor < term.line.length) { term.cursor++; termWrite('\x1b[C'); } return;
case '\x1b[A': { // history back
if (!term.history.length) return;
if (term.hIdx === null) { term.stash = term.line; term.hIdx = term.history.length; }
if (term.hIdx > 0) term.hIdx--;
termSetLine(term.history[term.hIdx]);
return;
}
case '\x1b[B': { // history forward
if (term.hIdx === null) return;
term.hIdx++;
if (term.hIdx >= term.history.length) { term.hIdx = null; termSetLine(term.stash); }
else termSetLine(term.history[term.hIdx]);
return;
}
case '\x1b[3~': { // Delete
if (term.cursor >= term.line.length) return;
term.line = term.line.slice(0, term.cursor) + term.line.slice(term.cursor + 1);
const rest = term.line.slice(term.cursor);
termWrite(rest + ' ' + `\x1b[${rest.length + 1}D`);
return;
}
default:
if (data >= ' ' || data.length > 1) termInsert(data.replace(/[\x00-\x1f]/g, ''));
}
}
function termClear() {
term.xterm?.clear();
// `clear` keeps the current row: redraw what was being typed so the cursor
// and the buffer don't disagree.
if (term.line) { const l = term.line, c = term.cursor; term.line = ''; term.cursor = 0; termInsert(l); term.cursor = c; }
}
/* ---- session wiring ---- */
async function termConnect() {
termStatus('pending', 'starting…');
termAlert('');
try {
const { id } = await api('/api/repl', { method: 'POST' });
term.replId = id;
} catch (e) {
termStatus('error', 'failed');
termAlert(`couldn't start the harness: ${e.message}`, 'error');
return;
}
const es = new EventSource(`/api/repl/${term.replId}/events`);
term.es = es;
es.addEventListener('data', (e) => termWrite(JSON.parse(e.data).chunk));
es.addEventListener('close', () => { es.close(); termStatus('off', 'session ended'); });
es.onopen = () => termStatus('on', 'connected');
es.onerror = () => { if (es.readyState === EventSource.CLOSED) termStatus('off', 'disconnected'); };
}
async function termRestart() {
if (term.replId) await api(`/api/repl/${term.replId}/stop`, { method: 'POST' }).catch(() => {});
term.es?.close();
term.es = null; term.replId = null;
term.xterm?.reset();
term.line = ''; term.cursor = 0;
await termConnect();
}
function termSetMode(mode) {
term.mode = mode;
if (mode === 'job') {
const job = state.currentJob;
termStatus(job && !job.done ? 'on' : 'off', job ? `engagement ${esc(job.name || job.target)}` : 'no live engagement');
termNote(job
? `attached to the running engagement — what you type goes to the same harness process that is testing ${job.name || job.target}.`
: 'no engagement is running; start one first.');
} else {
termStatus(term.replId ? 'on' : 'off', term.replId ? 'connected' : 'disconnected');
if (!term.replId) termConnect();
}
}
function termSyncTargets() {
const sel = $('#termTarget');
const job = state.currentJob;
const want = ['session'].concat(job ? ['job'] : []);
const have = $$('option', sel).map((o) => o.value);
if (want.join() === have.join()) {
if (job) sel.querySelector('option[value="job"]').textContent = `live: ${job.name || job.target}`;
return;
}
sel.innerHTML = `` +
(job ? `` : '');
sel.value = term.mode === 'job' && job ? 'job' : 'session';
if (term.mode === 'job' && !job) termSetMode('session');
}
function termOpen(mode) {
show($('#termDock'), true);
if (!termEnsure()) return;
termSyncTargets();
// Only switch to a target the select actually offers — asking for 'job' with
// no engagement running would leave the picker blank and the terminal
// pointing at nothing.
const wanted = mode && $(`#termTarget option[value="${mode}"]`) ? mode : null;
if (wanted && wanted !== term.mode) { $('#termTarget').value = wanted; termSetMode(wanted); }
else if (mode && !wanted) termNote('no engagement is running — this is the standalone REPL session.');
if (!term.booted) {
term.booted = true;
termNote('NeuroSploit harness — type /help for commands, or describe what you want tested in plain language.');
if (term.mode === 'session') termConnect();
}
termFit();
term.xterm.focus();
}
function termClose() { show($('#termDock'), false); }
function termToggle() { $('#termDock').hidden ? termOpen() : termClose(); }
$('#btnOpenRepl').addEventListener('click', () => termOpen());
$('#btnOpenTerm2').addEventListener('click', () => termOpen());
$('#btnOpenTerm3').addEventListener('click', () => termOpen());
$('#btnSendPromptTerm').addEventListener('click', () => termOpen('job'));
$('#btnTermClose').addEventListener('click', termClose);
$('#btnTermClear').addEventListener('click', termClear);
$('#btnTermRestart').addEventListener('click', () => { if (term.mode === 'job') termNote('the engagement owns this session — restart applies to the standalone REPL.'); else termRestart(); });
$('#btnTermExpand').addEventListener('click', () => {
const expanded = $('#termDock').classList.toggle('expanded');
$('#btnTermExpand').textContent = expanded ? 'collapse' : 'expand';
termFit();
});
$('#termTarget').addEventListener('change', (e) => termSetMode(e.target.value));
// Drag the dock's top edge. Height is remembered because the useful size
// depends on the screen, and re-dragging it every visit is the kind of small
// tax that makes a tool feel unfinished.
(function termResizer() {
const dock = $('#termDock');
const saved = Number(localStorage.getItem('ns-term-h'));
if (saved) dock.style.height = `${saved}px`;
let startY = 0, startH = 0, dragging = false;
$('#termResize').addEventListener('pointerdown', (e) => {
dragging = true; startY = e.clientY; startH = dock.getBoundingClientRect().height;
$('#termResize').setPointerCapture(e.pointerId);
document.body.classList.add('resizing-ns');
});
$('#termResize').addEventListener('pointermove', (e) => {
if (!dragging) return;
const h = Math.max(140, Math.min(window.innerHeight - 120, startH + (startY - e.clientY)));
dock.classList.remove('expanded');
dock.style.height = `${h}px`;
termFit();
});
const end = () => {
if (!dragging) return;
dragging = false;
document.body.classList.remove('resizing-ns');
localStorage.setItem('ns-term-h', String(Math.round(dock.getBoundingClientRect().height)));
};
$('#termResize').addEventListener('pointerup', end);
$('#termResize').addEventListener('pointercancel', end);
})();
document.addEventListener('keydown', (e) => {
if (e.key === '`' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); termToggle(); return; }
if (e.key !== 'Escape') return;
if (!$('#findingModal').hidden) return show($('#findingModal'), false);
if (!$('#leadModal').hidden) return closeLeadModal();
if (!$('#authModal').hidden) return show($('#authModal'), false);
if ($('#sidebar').classList.contains('open')) return setSidebar(false);
if (!$('#termDock').hidden) termClose();
});
// ---------------------------------------------------------------------------
// Dashboard — coverage, findings, and FAIR loss exposure
//
// The console could show one run at a time and nothing about the programme as a
// whole: how much has been tested, what keeps coming back, and what any of it
// is worth in money. The last question is the one a security owner is actually
// asked, and "14 highs" is not an answer to it.
//
// ## FAIR, and why the assumptions are on screen
//
// Risk here follows FAIR (Factor Analysis of Information Risk): annualized loss
// exposure = Loss Event Frequency × Loss Magnitude, where
//
// LEF = Threat Event Frequency × Vulnerability
//
// Both factors are estimates, not measurements. TEF (how often someone tries)
// is derived from the harness's own `exploitability` rating — a trivially
// exploitable bug on an internet-facing app gets attempted constantly, a hard
// one rarely. Vulnerability (how often an attempt succeeds) uses the finding's
// validation confidence, which is exactly what the multi-model vote measured.
// Loss magnitude cannot be derived from a scan at all: it depends on the
// business. So the defaults below are stated openly, shown in the UI, and
// editable — and the result is a RANGE, never a single number, because a point
// estimate of a distribution is the classic way risk quantification lies.
// ---------------------------------------------------------------------------
const FAIR_DEFAULTS = {
// Threat event frequency: attempts per year, by how easy the harness judged
// the finding to exploit.
tef: { trivial: 12, moderate: 4, hard: 1, unknown: 3 },
// Loss magnitude in USD per event: [minimum, most likely, maximum].
// Order-of-magnitude industry defaults — replace with your own loss data.
lm: {
critical: [250000, 1200000, 5000000],
high: [75000, 400000, 1500000],
medium: [15000, 80000, 300000],
low: [2000, 15000, 60000],
info: [0, 1000, 5000],
},
};
const FAIR_KEY = 'ns-fair-params';
function fairParams() {
try {
const saved = JSON.parse(localStorage.getItem(FAIR_KEY) || 'null');
if (saved?.tef && saved?.lm) return saved;
} catch { /* fall through to defaults */ }
return structuredClone(FAIR_DEFAULTS);
}
function saveFairParams(p) { localStorage.setItem(FAIR_KEY, JSON.stringify(p)); }
const money = (n) => {
if (!isFinite(n)) return '—';
if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `$${Math.round(n / 1e3)}K`;
return `$${Math.round(n)}`;
};
/// Annualized loss exposure for one finding, as [min, likely, max].
function fairForFinding(f, params) {
const sev = SEV_ORDER[sevRank(f.severity)];
const lm = params.lm[sev] || params.lm.info;
const tef = params.tef[(f.exploitability || 'unknown').toLowerCase()] ?? params.tef.unknown;
// A finding flagged for human review is a maybe, not a fact — halving its
// frequency keeps it visible without letting unreviewed leads drive the total.
const reviewFactor = f.reviewStatus === 'needs-review' ? 0.5 : 1;
const vuln = Math.min(0.95, Math.max(0.2, f.confidence || 0.5));
const lef = tef * vuln * reviewFactor;
return lm.map((m) => lef * m);
}
/// Heuristic posture score, 0-100. Deliberately simple and fully stated in the
/// UI: an opaque score invites arguing with the number instead of the findings.
///
/// Subtracting a fixed penalty per finding hit zero after one critical and a
/// handful of highs, which makes the score useless exactly when there is
/// something to track — a programme that fixes half its criticals must be able
/// to see the number move. The saturating form has diminishing returns instead,
/// so it keeps discriminating at any volume and never quite reaches 0.
const SCORE_WEIGHT = { critical: 10, high: 5, medium: 2, low: 0.5, info: 0.1 };
const SCORE_SCALE = 25;
function riskLoad(findings) {
return findings.reduce((acc, f) => {
const sev = SEV_ORDER[sevRank(f.severity)];
return acc + (SCORE_WEIGHT[sev] || 0) * Math.min(1, Math.max(0.3, f.confidence || 0.5));
}, 0);
}
function exposureScore(findings) {
return Math.round(100 / (1 + riskLoad(findings) / SCORE_SCALE));
}
function scoreBand(score) {
if (score >= 85) return { label: 'low exposure', cls: 'low' };
if (score >= 65) return { label: 'moderate exposure', cls: 'medium' };
if (score >= 40) return { label: 'high exposure', cls: 'high' };
return { label: 'critical exposure', cls: 'critical' };
}
/// One horizontal bar row: a label, a proportional fill, a direct value.
/// Values are labeled on every row, so the bar is a second encoding of a number
/// that is already readable — not the only way to read it.
function barRow(label, value, max, cls, title) {
const pct = max > 0 ? Math.max(2, (value / max) * 100) : 0;
return `
${esc(label)}${esc(String(value))}
`;
}
function dashRangeCutoff() {
const days = Number($('#dashRange')?.value || 0);
return days > 0 ? Date.now() / 1000 - days * 86400 : 0;
}
async function renderDashboard() {
const body = $('#dashBody');
let data;
try {
data = await api('/api/stats');
} catch (e) {
body.innerHTML = `
No runs in this range yet — start an engagement and the dashboard fills in.
';
return;
}
const targets = new Set(runs.map((r) => engagementKey(r.target)));
const sevCounts = {};
for (const f of findings) {
const s = SEV_ORDER[sevRank(f.severity)];
sevCounts[s] = (sevCounts[s] || 0) + 1;
}
const needsReview = findings.filter((f) => f.reviewStatus === 'needs-review').length;
const score = exposureScore(findings);
const band = scoreBand(score);
const ale = findings.reduce((acc, f) => {
const [lo, ml, hi] = fairForFinding(f, params);
return [acc[0] + lo, acc[1] + ml, acc[2] + hi];
}, [0, 0, 0]);
// Which findings actually drive the exposure — the reason to quantify at all
// is to rank remediation, and the ranking is what gets acted on.
const contributors = findings
.map((f) => ({ f, ml: fairForFinding(f, params)[1] }))
.sort((a, b) => b.ml - a.ml)
.slice(0, 6);
const byCwe = {};
for (const f of findings) {
if (!f.cwe) continue;
byCwe[f.cwe] = (byCwe[f.cwe] || 0) + 1;
}
const topCwe = Object.entries(byCwe).sort((a, b) => b[1] - a[1]).slice(0, 6);
const byTarget = {};
for (const r of runs) {
const k = engagementKey(r.target);
byTarget[k] = byTarget[k] || { runs: 0, findings: 0, last: 0 };
byTarget[k].runs++;
byTarget[k].findings += r.findings;
byTarget[k].last = Math.max(byTarget[k].last, r.ts);
}
const targetRows = Object.entries(byTarget).sort((a, b) => b[1].findings - a[1].findings);
const maxSev = Math.max(1, ...Object.values(sevCounts));
const maxCwe = Math.max(1, ...topCwe.map(([, n]) => n));
body.innerHTML = `
Engagements
${runs.length}
${targets.size} distinct target(s)
Findings
${findings.length}
${needsReview} awaiting human review
Agents run
${runs.reduce((a, r) => a + (r.agentsRan || 0), 0).toLocaleString('en-US')}
across ${runs.length} run(s)
Exposure score
${score}/100
${esc(band.label)}
Annualized loss exposure (FAIR)
minimum${money(ale[0])}
most likely${money(ale[1])}
maximum${money(ale[2])}
Loss Event Frequency × Loss Magnitude, summed over ${findings.length} finding(s).
Frequency comes from each finding's exploitability and validation confidence;
magnitude comes from the assumptions you set. A range, not a forecast.
Findings are summed independently, so shared root causes count twice —
read it as an upper bound on annual exposure, not a portfolio model.
Top contributors (most likely annual loss)
${contributors.map(({ f, ml }) => `
`).join('') || '
Score = 100 / (1 + risk / ${SCORE_SCALE}) where risk = Σ(severity weight × confidence) = ${riskLoad(findings).toFixed(1)}. Weights: critical ${SCORE_WEIGHT.critical}, high ${SCORE_WEIGHT.high}, medium ${SCORE_WEIGHT.medium}, low ${SCORE_WEIGHT.low}, info ${SCORE_WEIGHT.info}. A heuristic for tracking direction over time, not a certification.
`;
$('#btnFairParams').addEventListener('click', () => openFairParams(params));
$$('#dashBody tbody tr[data-target]').forEach((tr) => tr.addEventListener('click', () => {
state.runFilter = tr.dataset.target;
$('#runFilter').value = tr.dataset.target;
renderSidebar();
}));
// A top contributor is a specific finding in a specific run — clicking it
// opens that run and pops the finding's full detail (evidence/impact/PoC),
// so "what is this loss" is one click, not a hunt through run history.
$$('#dashBody .contrib-click').forEach((row) => row.addEventListener('click', () =>
openContributorFinding(row.dataset.run, row.dataset.title, row.dataset.cwe)));
}
/// Open the run a dashboard contributor belongs to and surface that exact
/// finding. The dashboard row carries only (runId, title, cwe) — the full
/// finding lives in the run detail — so we load the run, then match by title
/// (falling back to CWE) and open its modal.
async function openContributorFinding(runId, title, cwe) {
if (!runId) return;
const run = (state.runs || []).find((r) => r.id === runId) || { id: runId, state: 'complete' };
// Switch to the run's detail view (this also refreshes the tables), and in
// parallel fetch the detail directly so we match the finding without racing
// loadDetail's own async fill of state.detailFindings.
openRun(run);
let detail;
try { detail = await api(`/api/runs/${encodeURIComponent(runId)}`); }
catch { return; }
const fs = detail.findings || [];
const norm = (s) => String(s || '').trim().toLowerCase();
let f = fs.find((x) => norm(x.title) === norm(title));
if (!f && cwe) f = fs.find((x) => norm(x.cwe) === norm(cwe));
if (f) {
openFindingModal(f, detail.pocs || [], runId);
} else {
toast('Opened the run — this finding was recalibrated or merged, so its row may differ.', 'warn', 6000);
}
}
/// The assumptions panel. FAIR without visible inputs is a magic number; with
/// them it is a model the reader can disagree with concretely.
function openFairParams(params) {
const p = structuredClone(params);
const body = $('#dashBody');
const host = document.createElement('div');
host.className = 'modal-overlay';
host.innerHTML = `
FAIR assumptions
Threat event frequency — attempted events per year, by exploitability.
${Object.keys(p.tef).map((k) => `
`).join('')}
Loss magnitude per event (USD): minimum / most likely / maximum.
${Object.keys(p.lm).map((k) => `
`).join('')}
`;
document.body.appendChild(host);
const close = () => host.remove();
host.addEventListener('click', (e) => { if (e.target === host) close(); });
host.querySelector('[data-close]').addEventListener('click', close);
host.querySelector('[data-reset]').addEventListener('click', () => {
saveFairParams(structuredClone(FAIR_DEFAULTS));
close();
renderDashboard();
});
host.querySelector('[data-save]').addEventListener('click', () => {
host.querySelectorAll('[data-tef]').forEach((i) => { p.tef[i.dataset.tef] = Number(i.value) || 0; });
host.querySelectorAll('[data-lm]').forEach((i) => { p.lm[i.dataset.lm][Number(i.dataset.i)] = Number(i.value) || 0; });
// A max below the minimum would silently invert the range.
for (const k of Object.keys(p.lm)) p.lm[k].sort((a, b) => a - b);
saveFairParams(p);
close();
renderDashboard();
});
body.scrollTop = body.scrollTop; // keep the page anchored while the modal opens
}
function showDashboard() {
leaveLiveJob();
clearInterval(state.detailPoll);
show($('#wizardView'), false);
show($('#liveView'), false);
show($('#detailView'), false);
show($('#dashView'), true);
renderDashboard();
}
$('#btnDashboard').addEventListener('click', showDashboard);
$('#btnDashRefresh').addEventListener('click', renderDashboard);
$('#dashRange').addEventListener('change', renderDashboard);
$('#runFilter').addEventListener('input', (e) => { state.runFilter = e.target.value; renderSidebar(); });
// ---------------------------------------------------------------------------
// boot
// ---------------------------------------------------------------------------
// Below 900px the sidebar slides off-canvas. It previously had no way back:
// the CSS hid it and nothing could set .open.
const NARROW = '(max-width: 900px)';
function setSidebar(open) {
$('#sidebar').classList.toggle('open', open);
show($('#sidebarScrim'), open && window.matchMedia(NARROW).matches);
$('#btnSidebarToggle').setAttribute('aria-expanded', String(open));
}
$('#btnSidebarToggle').addEventListener('click', () => setSidebar(!$('#sidebar').classList.contains('open')));
$('#sidebarScrim').addEventListener('click', () => setSidebar(false));
// Picking a run is the drawer's whole purpose — it should get out of the way
// once you have.
$('#sbGroups').addEventListener('click', (e) => {
if (e.target.closest('.sb-run') && window.matchMedia(NARROW).matches) setSidebar(false);
});
$('#btnDashboard').addEventListener('click', () => { if (window.matchMedia(NARROW).matches) setSidebar(false); });
$('#btnNewEngagement').addEventListener('click', () => { if (window.matchMedia(NARROW).matches) setSidebar(false); });
// Growing the window past the breakpoint leaves a scrim over a sidebar that is
// no longer a drawer.
window.matchMedia(NARROW).addEventListener('change', (m) => { if (!m.matches) setSidebar(false); });
async function boot() {
applyTheme();
selectMode('run');
goToStep(0);
renderCustomLeads();
renderFindings('live');
renderFindings('detail');
const meta = await api('/api/meta').catch(() => ({}));
$('#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
await offerInterruptedJobs(); // a run the server lost on restart/crash
setInterval(refreshRuns, 6000);
}
boot();
// A job that was live when the server stopped is reloaded from disk as
// `interrupted`. When it is resumable (a REPL-backed run/whitebox/greybox, the
// ones the harness checkpoints), offer a one-click resume: the server relaunches
// the REPL, which auto-recovers the on-disk checkpoint and /continue's it.
async function offerInterruptedJobs() {
let jobs;
try { jobs = await api('/api/exploit'); } catch { return; }
const resumable = (jobs || []).filter((j) => j.interrupted && j.resumable);
if (!resumable.length) return;
const bar = document.createElement('div');
bar.className = 'resume-bar';
bar.innerHTML = `
↻${resumable.length} interrupted run(s) — the server restarted mid-engagement. Findings are saved; resume to continue where it left off.
`;
const actions = bar.querySelector('.resume-actions');
for (const j of resumable) {
const b = document.createElement('button');
b.className = 'btn btn-sm btn-primary';
b.textContent = `Resume ${j.name || j.target || j.id.slice(0, 8)}`;
b.addEventListener('click', async () => {
b.disabled = true; b.textContent = 'Resuming…';
try {
await api(`/api/exploit/${j.id}/resume`, { method: 'POST' });
localStorage.setItem(ACTIVE_JOB_KEY, j.id);
attachLiveJob(j.id, j.target, j.name, j.pinnedAgents || []);
bar.remove();
} catch (e) {
b.disabled = false; b.textContent = 'Retry';
toast(`Couldn't resume: ${e.message}`, 'error', 9000);
}
});
actions.appendChild(b);
}
bar.querySelector('.resume-x').addEventListener('click', () => bar.remove());
document.body.insertBefore(bar, document.body.firstChild);
}
// ---------------------------------------------------------------------------
// Run detail — attack graph, dependencies, traffic, traces, deliverables.
//
// Every tab reads files the CLI wrote into the run directory, and every action
// (recompute the graph, push to Neo4j, re-test the PoCs) shells out to the CLI
// through the server — the console never re-implements harness logic.
// ---------------------------------------------------------------------------
function renderKpis(sel, all) {
const el = $(sel);
if (!el) return;
const by = (fn) => all.filter(fn).length;
const sev = (s) => by((f) => SEV_ORDER[sevRank(f.severity)] === s);
const cards = [
{ k: 'Findings', v: all.length, sub: `${sev('critical')} critical · ${sev('high')} high · ${sev('medium')} medium · ${sev('low')} low` },
{ k: 'Confirmed', v: by((f) => reviewStatus(f) === 'confirmed'), sub: `${by((f) => reviewStatus(f) === 'needs-review')} need review`, cls: 'kpi-ok' },
{ k: 'Wire-verified', v: by((f) => wireState(f).key === 'verified'), sub: 'evidence re-observed on the wire', cls: 'kpi-ok' },
{ k: 'Contradicted', v: by((f) => wireState(f).key === 'contradicted'), sub: 'the wire disagreed with the agent', cls: 'kpi-bad' },
];
el.innerHTML = cards.map((c) => `
`;
document.body.appendChild(host);
const close = () => host.remove();
host.addEventListener('click', (e) => { if (e.target === host || e.target.hasAttribute('data-close')) close(); });
}
// ---- the extras component ---------------------------------------------------
//
// Attack graph · Chains · Dependencies · Traffic · Traces · Deliverables, mounted into
// both the past-run detail view and the live view. Each mount owns its state
// and queries only inside its own root, so the two never collide.
const EXTRAS_TABS = [
['graph', 'Attack graph'], ['chains', 'Chains'], ['todo', 'Dependencies'],
['traffic', 'Traffic'], ['traces', 'Traces'], ['deliverables', 'Deliverables'],
];
const STATUS_LABEL = { confirmed: 'confirmed', needs_validation: 'needs validation', hypothesis: 'hypothesis', needs_review: 'needs review' };
function extrasPanelsHtml(uid) {
return `
Chain severity rises above the findings only when every step is evidenced.
No multi-step path to an impact yet. Findings keep their local severity.
Each open chain, step by step in dependency order. ready steps are what the chaining rounds dispatch next; blocked steps wait for the fact an earlier step yields; steps turn done when the graph holds evidence for them.
Nothing pending — every chain is confirmed, or there is no multi-step path yet.
No attack graph yet — it is written at every graph checkpoint of a run, or press ↻ Graph on a finished run.
⌕
Time
Host
Method
URL
Status
Type
Size
No recorded traffic. The recording interceptor runs with --intercept own; findings are still wire-checked by the harness's own replay.
⌕
No agent traces yet.
No deliverables yet — they are written with the report.
`;
}
function makeExtras(viewSel, uid, getRunId) {
const root = $(viewSel);
const nav = $('.run-tabs', root);
const body = $('.run-body', root);
if (!root || !nav || !body) return null;
for (const [tab, label] of EXTRAS_TABS) {
const b = document.createElement('button');
b.className = 'run-tab';
b.dataset.tab = tab;
// Only tabs with a meaningful number carry a counter.
b.innerHTML = ['chains', 'todo', 'traces', 'deliverables'].includes(tab) ? `${esc(label)} 0` : esc(label);
nav.appendChild(b);
}
body.insertAdjacentHTML('beforeend', extrasPanelsHtml(uid));
const x = {
root, getRunId, graphData: null, extras: {}, chainFilter: 'all',
view: { k: 1, tx: 20, ty: 20, sel: null }, timers: {},
q: (k) => $(`[data-x="${k}"]`, root),
count: (tab, n) => { const el = $(`[data-count="${tab}"]`, root); if (el) el.textContent = n; },
active: () => $('.run-tab.active', root)?.dataset.tab || 'findings',
};
// Our tabs switch panels like the built-in ones (whose handlers already
// hide every .run-tab-panel in this view, ours included).
$$('.run-tab', nav).forEach((t) => {
if (!EXTRAS_TABS.some(([k]) => k === t.dataset.tab)) return;
t.addEventListener('click', () => {
$$('.run-tab', root).forEach((o) => o.classList.remove('active'));
t.classList.add('active');
$$('.run-tab-panel', root).forEach((p) => show(p, p.dataset.tabpanel === t.dataset.tab));
extrasLoad(x, t.dataset.tab);
});
});
// Inputs.
const debounce = (k, fn) => () => { clearTimeout(x.timers[k]); x.timers[k] = setTimeout(fn, 250); };
x.q('t-host').addEventListener('input', debounce('t', () => extrasTraffic(x)));
x.q('t-q').addEventListener('input', debounce('t', () => extrasTraffic(x)));
x.q('t-method').addEventListener('change', () => extrasTraffic(x));
x.q('tr-q').addEventListener('input', debounce('tr', () => extrasTraces(x)));
x.q('full').addEventListener('change', () => renderGraphMemory(x));
bindGraphInteractions(x);
return x;
}
async function extrasGraph(x, force) {
const id = x.getRunId();
if (!id) return {};
if (!force && x.graphData && x.graphData.id === id) return x.graphData;
const g = await api(`/api/runs/${encodeURIComponent(id)}/graph`);
if (id !== x.getRunId()) return x.graphData || {};
x.graphData = { id, ...g };
return x.graphData;
}
function extrasCounts(x) {
const g = x.graphData || {};
x.count('chains', (g.chains?.chains || []).length);
x.count('todo', (g.todo || []).filter((t) => t.state === 'ready').length);
x.count('traces', x.extras.traces || 0);
x.count('deliverables', (x.extras.deliverables || []).length);
}
// Refresh after the run's metadata changed (detail load, or a live tick).
async function extrasRefresh(x, extras, force) {
if (!x) return;
const id = x.getRunId();
if (x.lastId !== id) { x.lastId = id; x.graphData = null; x.view = { k: 1, tx: 20, ty: 20, sel: null }; }
x.extras = extras || {};
try { await extrasGraph(x, force); } catch { /* no graph yet */ }
extrasCounts(x);
const tab = x.active();
if (EXTRAS_TABS.some(([k]) => k === tab)) extrasLoad(x, tab, true);
}
async function extrasLoad(x, tab, quiet) {
try {
if (tab === 'chains') renderChains(x, await extrasGraph(x));
else if (tab === 'todo') renderTodo(x, await extrasGraph(x));
else if (tab === 'graph') {
const g = (await extrasGraph(x))?.graph;
const sig = g ? `${(g.nodes || []).length}:${(g.edges || []).length}` : '';
// Redraw only when the graph changed, so a live refresh keeps the
// operator's zoom, pan and selection.
if (!quiet || sig !== x.drawnSig) { x.drawnSig = sig; renderGraphMemory(x); }
}
else if (tab === 'traffic') await extrasTraffic(x);
else if (tab === 'traces') await extrasTraces(x);
else if (tab === 'deliverables') renderDeliverables(x);
} catch (e) {
if (!quiet) toast(`Could not load ${tab}: ${e.message}`, 'error');
}
}
// ---- chains ----------------------------------------------------------------
function renderChains(x, g) {
const all = g?.chains?.chains || [];
const moves = g?.chains?.next_moves || [];
const counts = { all: all.length };
for (const c of all) counts[c.status] = (counts[c.status] || 0) + 1;
x.q('chain-filters').innerHTML = ['all', 'confirmed', 'needs_validation', 'hypothesis']
.map((k) => ``).join('');
$$('[data-cf]', x.q('chain-filters')).forEach((b) => b.addEventListener('click', () => { x.chainFilter = b.dataset.cf; renderChains(x, g); }));
const mv = (m) => `