`;
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)
// ---------------------------------------------------------------------------
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: '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),
subscription: state.authMode === 'subscription',
mcp: $('#fieldMcp').checked,
agents: [...state.selected],
focus: focusParts.join('; ') || undefined,
objective: $('#fieldObjective').value.trim() || undefined,
outOfScope: $('#fieldOutOfScope').value.trim() || undefined,
auth: state.auth.header || undefined,
roles: state.auth.roles.length ? state.auth.roles : undefined,
creds: state.credsPath || undefined,
};
$('#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,
};
localStorage.setItem(ACTIVE_JOB_KEY, id);
show($('#wizardView'), false);
show($('#detailView'), false);
show($('#liveView'), true);
$('#liveTarget').textContent = name || target || '—';
$('#liveTargetSub').textContent = name ? target : '';
$('#livePhase').textContent = 'starting';
$('#phaseDot').style.background = '';
$('#phaseDot').classList.remove('static');
$('#liveAttackPath').innerHTML = '';
$('#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('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}`);
}
});
function findingRow(f, idx) {
return `
${esc(f.severity)}
${esc(f.title)}
${esc(f.endpoint)}
${esc(f.cwe)}
${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' },
};
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 cmp = String(a.f[key] || '').localeCompare(String(b.f[key] || ''));
return cmp * t.dir;
});
$(cfg.tbody).innerHTML = rows.map(({ f, idx }) => findingRow(f, idx)).join('');
$(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');
renderAttackPath($('#liveAttackPath'), state.currentJob.findings, state.currentJob.target);
}
function applySnapshot(snap) {
$('#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();
}
$('#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);
}
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' });
});
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($('#wizardView'), true); });
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#wizardView'), true); });
$('#btnNewEngagement').addEventListener('click', () => { leaveLiveJob(); clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#wizardView'), true); });
// ---------------------------------------------------------------------------
// Generative Attack Path Chaining
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Finding detail modal — full evidence/impact/remediation + any PoC script
// ---------------------------------------------------------------------------
function openFindingModal(f, pocs, runId) {
$('#fmSev').className = `sev ${sevClass(f.severity)}`;
$('#fmSev').textContent = f.severity || 'info';
$('#fmTitle').textContent = f.title || '(untitled finding)';
const meta = [
['CWE', f.cwe], ['CVSS', f.cvss], ['OWASP', f.owasp], ['MITRE', f.mitre],
['Stage', f.stage], ['Exploitability', f.exploitability],
['Confidence', f.confidence ? f.confidence.toFixed(2) : ''], ['Votes', f.votes],
['Review status', f.review_status], ['Auth context', f.auth_context],
['Account', f.account], ['Agent', f.agent],
];
$('#fmMeta').innerHTML = meta.map(([k, v]) =>
`
${esc(k)}
${esc(v || '—')}
`).join('');
// 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
? `
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.
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) => `
`).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'];
// Same severity tokens the rest of the console uses — the graph canvas
// follows the light/dark theme instead of a fixed dark palette.
function canvasColor(sev) { return `var(--sev-${['critical', 'high', 'medium', 'low', 'info'][sevRank(sev)]}-fg)`; }
function nodeIcon(f) {
const t = `${f.title} ${f.evidence} ${f.cwe} ${f.stage}`.toLowerCase();
if (/credential|password|secret|token|api[ _]?key|jwt/.test(t)) return '🔑';
if (/admin|privile|domain admin|root/.test(t)) return '🛡';
if (/account|user|identity/.test(t)) return '👤';
if (/host|server|ip |port|service/.test(t)) return '🖥';
if (/database|sql/.test(t)) return '🗄';
if (t.includes('impact') || t.includes('exfil')) return '💥';
return '⚠';
}
// Generative Attack Path Chaining — a real node graph (root = target, one
// node per confirmed finding, edges from chains_from when the harness set
// it, else fanned from root) instead of flat cards, so a single finding
// still reads as a graph and not an empty list.
function renderAttackPath(container, findings, target) {
if (!findings.length) {
container.innerHTML = '
The attack path builds automatically as findings chain together — nothing confirmed yet.