'use strict'; /* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */ const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const MODE_LABELS = { run: { target: 'Target URL', help: 'The application to test.', showRepo: false, placeholder: 'https://target.example.com' }, whitebox: { target: 'Source repo / path', help: "A GitHub URL, owner/repo shorthand, or a local path — cloned automatically if it's remote.", showRepo: false, placeholder: 'owner/repo' }, greybox: { target: 'Target URL', help: 'The running application to exploit, alongside the source repo below.', showRepo: true, placeholder: 'https://target.example.com' }, host: { target: 'Target host / IP', help: 'Runs Linux / Windows / Active Directory agents.', showRepo: false, placeholder: '10.0.0.10' }, aitest: { target: 'AI endpoint URL', help: 'A live AI agent, LLM chat, or MCP endpoint (OWASP LLM Top 10).', showRepo: false, placeholder: 'https://target.example.com/chat' }, }; const STEP_COUNT = 5; const state = { theme: localStorage.getItem('ns-theme') || 'light', step: 0, mode: 'run', categories: [], selected: new Set(), customLeads: [], filter: 'all', search: '', expandedCats: new Set(), providers: [], auth: { header: '', roles: [] }, credsPath: '', // Engagement authorization: the grant, plus settings that may only narrow it. authz: { capability: '', inScope: '', environment: 'production', policyProfile: 'web', transport: '', oobDomain: '', oobHttp: '', oobDns: '', sms: '' }, keys: [], runs: [], currentJob: null, currentDetailId: null, detailPoll: null, // Findings tables (live + past run) share one sort/filter model so the two // views can't drift into behaving differently. tables: { live: { sort: 'severity', dir: 1, query: '', sev: null }, detail: { sort: 'severity', dir: 1, query: '', sev: null }, }, }; // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } async function api(path, opts) { const res = await fetch(path, opts); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `${path} → ${res.status}`); } return res.headers.get('content-type')?.includes('json') ? res.json() : res.text(); } function sevRank(sev) { const s = (sev || '').toLowerCase(); if (s.includes('crit')) return 0; if (s.includes('high')) return 1; if (s.includes('med')) return 2; if (s.includes('low')) return 3; return 4; } function sevClass(sev) { return ['sev-critical', 'sev-high', 'sev-medium', 'sev-low', 'sev-info'][sevRank(sev)]; } function show(el, on) { if (el) el.hidden = !on; } // Failures used to surface through `alert()`, which blocks the page and hides // the very screen the operator needs to fix. Toasts stay out of the way and // let several messages stack during a run. function toast(msg, kind = 'info', ms = 5000) { const root = $('#toasts'); if (!root) return; const el = document.createElement('div'); el.className = `toast toast-${kind}`; el.textContent = msg; el.addEventListener('click', () => el.remove()); root.appendChild(el); if (ms) setTimeout(() => el.remove(), ms); return el; } function fieldError(id, msg) { const el = $(id); if (!el) return; el.textContent = msg || ''; show(el, !!msg); } function clearFieldErrors() { $$('.field-error').forEach((el) => { el.textContent = ''; el.hidden = true; }); } function timeAgo(ts) { if (!ts) return ''; const secs = Math.max(0, Math.floor(Date.now() / 1000 - ts)); if (secs < 60) return 'just now'; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}d ago`; return new Date(ts * 1000).toLocaleDateString(); } // --------------------------------------------------------------------------- // theme // --------------------------------------------------------------------------- function applyTheme() { document.documentElement.setAttribute('data-theme', state.theme); $('#btnThemeToggle').textContent = state.theme === 'dark' ? '☀' : '☾'; $('#btnThemeToggle').title = state.theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'; // xterm paints into a canvas and doesn't inherit CSS variables — it has to // be told the palette changed. if (term.xterm) term.xterm.options.theme = termColors(); } $('#btnThemeToggle').addEventListener('click', () => { state.theme = state.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('ns-theme', state.theme); applyTheme(); }); // --------------------------------------------------------------------------- // wizard — step navigation // --------------------------------------------------------------------------- function goToStep(n) { state.step = Math.max(0, Math.min(STEP_COUNT - 1, n)); $$('.step-tab').forEach((tab, i) => { tab.classList.toggle('active', i === state.step); tab.classList.toggle('done', i < state.step); }); $$('.wizard-panel').forEach((panel) => show(panel, Number(panel.dataset.panel) === state.step)); show($('#btnStepBack'), state.step > 0); show($('#btnStepNext'), state.step < STEP_COUNT - 1); show($('#btnLaunch'), state.step === STEP_COUNT - 1); if (state.step === STEP_COUNT - 1) renderReview(); updateWizardSummary(); // The stepper scrolls horizontally on a phone; advancing to a step that is // off-screen would look like nothing happened. const active = $('.step-tab.active'); if (active?.scrollIntoView) active.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' }); } // Errors land next to the field they belong to. A modal alert forced the // operator to dismiss the message before they could see (or fix) the input it // was about — and lost it entirely once dismissed. function validateStep(n) { if (n !== 0) return true; clearFieldErrors(); let firstBad = null; if (!$('#fieldName').value.trim()) { fieldError('#errName', 'Name the engagement — this is how it is labelled in the sidebar and run history.'); firstBad = firstBad || '#fieldName'; } if (!$('#fieldTarget').value.trim()) { fieldError('#errTarget', `${MODE_LABELS[state.mode].target} is required.`); firstBad = firstBad || '#fieldTarget'; } if (state.mode === 'greybox' && !$('#fieldRepo').value.trim()) { fieldError('#errRepo', 'Grey-box tests the running app against its source — the repo is required.'); firstBad = firstBad || '#fieldRepo'; } if (firstBad) { $(firstBad).focus(); return false; } return true; } $('#btnStepNext').addEventListener('click', () => { if (validateStep(state.step)) goToStep(state.step + 1); }); $('#btnStepBack').addEventListener('click', () => goToStep(state.step - 1)); $$('.step-tab').forEach((tab) => tab.addEventListener('click', () => { const n = Number(tab.dataset.step); if (n <= state.step || validateStep(state.step)) goToStep(n); })); function updateWizardSummary() { const name = $('#fieldName').value.trim() || '(unnamed)'; const target = $('#fieldTarget').value.trim() || '(not set)'; $('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · ${esc(name)} · ${esc(state.mode)} · ${esc(target)}`; } $('#fieldName').addEventListener('input', () => { updateWizardSummary(); fieldError('#errName', ''); }); $('#fieldTarget').addEventListener('input', () => fieldError('#errTarget', '')); $('#fieldRepo').addEventListener('input', () => fieldError('#errRepo', '')); // mode tiles function selectMode(mode) { state.mode = mode; $$('.mode-tile').forEach((t) => t.classList.toggle('selected', t.dataset.mode === mode)); const cfg = MODE_LABELS[mode]; $('#targetLabel').textContent = cfg.target; $('#targetHelp').textContent = cfg.help; $('#fieldTarget').placeholder = cfg.placeholder; show($('#fieldRepoGroup'), cfg.showRepo); updateWizardSummary(); } $$('.mode-tile').forEach((tile) => tile.addEventListener('click', () => selectMode(tile.dataset.mode))); $('#fieldTarget').addEventListener('input', updateWizardSummary); // --------------------------------------------------------------------------- // agents / lead board (step 3) // --------------------------------------------------------------------------- async function loadAgents() { const data = await api('/api/agents'); state.categories = data.categories; renderBoard(); } function renderBoard() { const root = $('#categories'); root.innerHTML = ''; for (const group of state.categories) { const selCount = group.agents.filter((a) => state.selected.has(a.id)).length; const card = document.createElement('div'); // 412 leads across ~30 categories: expanded by default that is a wall of // switches you have to scroll past to reach anything. Collapsed keeps the // whole taxonomy on one screen; a search auto-expands what it matches. const open = state.expandedCats.has(group.category); card.className = 'cat-card' + (open ? '' : ' collapsed'); card.dataset.category = group.category; card.innerHTML = `
${esc(group.category)} ${selCount} / ${group.agents.length} ▾
`; 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: '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), 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, 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, }; localStorage.setItem(ACTIVE_JOB_KEY, id); 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'); $('#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(); } 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; } }); $('#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); }); // --------------------------------------------------------------------------- // 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 ? `
${esc(text)}
` : ''; const proseBlock = (label, text) => text ? `
${esc(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) => `
pocs/${esc(name)} Open raw
loading…
`).join(''); for (const name of list) { fetch(`/api/runs/${runId}/asset/pocs/${name}`).then((r) => r.text()).then((txt) => { const pre = pocRoot.querySelector(`pre[data-poc="${CSS.escape(name)}"]`); if (pre) pre.textContent = txt.slice(0, 4000); }).catch(() => {}); } } show($('#findingModal'), true); } $('#btnCloseFinding').addEventListener('click', () => show($('#findingModal'), false)); $('#findingModal').addEventListener('click', (e) => { if (e.target.id === 'findingModal') show($('#findingModal'), false); }); // --------------------------------------------------------------------------- // Generative Attack Path Chaining // // The graph answers one question: how does an attacker get from the target to // impact? Three things it must not do, each of which the first version did: // // 1. **Drop findings.** Stages were matched against a hardcoded list of seven, // so anything the harness emitted outside it (`credential-access`, // `discovery`, `persistence`, …) silently vanished — 5 of 27 findings on a // real run. The stage list now mirrors `knowledge_graph::STAGES`, and any // unknown stage still gets its own column rather than being discarded. // 2. **Blur into unreadable boxes.** Titles were cut at 22 characters, so a // column read "SQL Injection Authent…" six times. Nodes now wrap onto two // lines and carry CWE / technique / exploitability. // 3. **Present a guess as evidence.** Agents only sometimes fill `chains_from`. // Without it every node fanned off the root, which looks like a chain and // is not one. Inferred progression edges are drawn dashed, counted // separately in the toolbar, and can be hidden. // // When a run wrote `graph.json` (the harness's own knowledge graph), its edges // are used verbatim — including which ones it inferred. Older runs fall back to // deriving the same shape client-side, so the view degrades rather than empties. // --------------------------------------------------------------------------- // Mirrors knowledge_graph::STAGES on the Rust side. Order = attack progression. const KILL_CHAIN_STAGES = [ 'recon', 'discovery', 'initial-access', 'execution', 'persistence', 'privesc', 'credential-access', 'lateral', 'collection', 'exfil', 'impact', ]; const stageRank = (s) => { const i = KILL_CHAIN_STAGES.indexOf(s); return i === -1 ? KILL_CHAIN_STAGES.length : i; }; // 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 '⚠'; } /// Greedy wrap into at most `lines` lines of `max` chars, ellipsizing the tail. function wrapLabel(s, max, lines) { const words = String(s || '').split(/\s+/).filter(Boolean); const out = []; let cur = ''; for (const w of words) { const next = cur ? `${cur} ${w}` : w; if (next.length <= max) { cur = next; continue; } if (out.length === lines - 1) { cur = `${next.slice(0, max - 1)}…`; break; } out.push(cur || w.slice(0, max)); cur = cur ? w : ''; } if (cur) out.push(cur); return out.slice(0, lines); } /// Chain edges between findings, and where they came from. /// Returns `{ edges: [{from, to, inferred}], source }` with indices into /// `findings`, so the caller can tell the operator what it is looking at. function chainEdges(findings, graph) { const byId = new Map(findings.map((f, i) => [f.id, i])); // 1. The harness's own graph, when the run wrote one. if (graph?.edges?.length) { const nodeToFinding = new Map(); for (const [id, n] of Object.entries(graph.nodes || {})) { const fid = n.meta?.finding_id; if (n.kind === 'finding' && fid !== undefined && byId.has(fid)) nodeToFinding.set(id, byId.get(fid)); } const edges = []; for (const e of graph.edges) { if (e.kind !== 'chains') continue; const a = nodeToFinding.get(e.from), b = nodeToFinding.get(e.to); if (a !== undefined && b !== undefined && a !== b) edges.push({ from: a, to: b, inferred: !!e.inferred }); } if (edges.length) return { edges, source: edges.every((e) => e.inferred) ? 'graph-inferred' : 'graph' }; } // 2. Edges the agents asserted on the findings themselves. const asserted = []; findings.forEach((f, i) => { for (const src of f.chains_from || []) { const a = byId.get(src); if (a !== undefined && a !== i) asserted.push({ from: a, to: i, inferred: false }); } }); if (asserted.length) return { edges: asserted, source: 'asserted' }; // 3. Derive progression the same way the harness does: forward only, between // adjacent populated stages, from the strongest finding of the earlier one. // A full cross-product would look richer and mean nothing. const byStage = new Map(); findings.forEach((f, i) => { const r = stageRank(f.stage || ''); if (!byStage.has(r)) byStage.set(r, []); byStage.get(r).push(i); }); const ranks = [...byStage.keys()].sort((a, b) => a - b); const weight = (i) => (4 - sevRank(findings[i].severity)) * (findings[i].confidence || 0.5); const edges = []; for (let k = 0; k + 1 < ranks.length; k++) { const from = byStage.get(ranks[k]).slice().sort((a, b) => weight(b) - weight(a))[0]; for (const to of byStage.get(ranks[k + 1])) edges.push({ from, to, inferred: true }); } return { edges, source: edges.length ? 'derived' : 'none' }; } const AP_SEV_FILTERS = ['all', 'critical', 'high', 'medium', 'low']; function renderAttackPath(container, allFindings, target, graph) { const view = (container.__ap = container.__ap || { k: 1, tx: 0, ty: 0, sev: 'all', hideInferred: false, fitted: false }); if (!allFindings.length) { container.innerHTML = '
The attack path builds automatically as findings chain together — nothing confirmed yet.
'; return; } const minRank = view.sev === 'all' ? 99 : sevRank(view.sev); const findings = view.sev === 'all' ? allFindings : allFindings.filter((f) => sevRank(f.severity) <= minRank); if (!findings.length) { container.innerHTML = `
No ${esc(view.sev)}-or-higher finding to chain.
`; container.querySelector('[data-ap-reset]')?.addEventListener('click', () => { view.sev = 'all'; renderAttackPath(container, allFindings, target, graph); }); return; } const model = chainEdges(findings, graph); const edges = view.hideInferred ? model.edges.filter((e) => !e.inferred) : model.edges; // ---- columns: one per stage actually present, in progression order -------- const groups = new Map(); findings.forEach((f, i) => { const key = (f.stage || '').trim() || 'unstaged'; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(i); }); const cols = [...groups.entries()].sort((a, b) => { const ra = a[0] === 'unstaged' ? 999 : stageRank(a[0]); const rb = b[0] === 'unstaged' ? 999 : stageRank(b[0]); return ra - rb || a[0].localeCompare(b[0]); }); for (const [, idxs] of cols) { idxs.sort((a, b) => sevRank(findings[a].severity) - sevRank(findings[b].severity) || (findings[b].confidence || 0) - (findings[a].confidence || 0)); } const NODE_W = 236, NODE_H = 66, COL_GAP = 88, ROW_GAP = 14, PAD = 28, HEAD_H = 34, ROOT_W = 132; const rowH = NODE_H + ROW_GAP; const tallest = Math.max(...cols.map(([, v]) => v.length)); const bodyH = tallest * rowH; const pos = new Map(); // finding index -> {x, y} cols.forEach(([, idxs], ci) => { const x = PAD + ROOT_W + ci * (NODE_W + COL_GAP); const colH = idxs.length * rowH; const top = PAD + HEAD_H + (bodyH - colH) / 2; idxs.forEach((fi, ri) => pos.set(fi, { x, y: top + ri * rowH })); }); const width = PAD * 2 + ROOT_W + cols.length * NODE_W + Math.max(0, cols.length - 1) * COL_GAP; const height = PAD * 2 + HEAD_H + bodyH; const rootY = PAD + HEAD_H + bodyH / 2; const hasParent = new Set(edges.map((e) => e.to)); const roots = findings.map((_, i) => i).filter((i) => !hasParent.has(i)); const curve = (x1, y1, x2, y2) => { const mid = (x1 + x2) / 2; return `M ${x1},${y1} C ${mid},${y1} ${mid},${y2} ${x2},${y2}`; }; const edgeSvg = edges.map((e, i) => { const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) return ''; return ``; }).join(''); const rootEdges = roots.map((i) => { const b = pos.get(i); return ``; }).join(''); const nodeSvg = findings.map((f, i) => { const p = pos.get(i); if (!p) return ''; const color = canvasColor(f.severity); const title = wrapLabel(f.title, 30, 2); const meta = [f.cwe, f.mitre || f.owasp, f.exploitability].filter(Boolean).join(' · '); return ` ${nodeIcon(f)} ${title.map((line, li) => `${esc(line)}`).join('')} ${esc(meta || f.agent)} ${f.confidence ? f.confidence.toFixed(2) : ''} `; }).join(''); // Column headers name the stage and count it — unlabelled separator lines // made the columns unreadable, which defeats a kill-chain layout entirely. const headSvg = cols.map(([stage, idxs], ci) => { const x = PAD + ROOT_W + ci * (NODE_W + COL_GAP); return ` ${esc(stage.replace(/-/g, ' '))} ${idxs.length} `; }).join(''); const inferredCount = model.edges.filter((e) => e.inferred).length; const provenance = { graph: 'chain edges from this run\'s knowledge graph', 'graph-inferred': 'no chain was asserted — progression inferred by the harness', asserted: 'chain edges asserted by the agents', derived: 'no chain was asserted — progression inferred from kill-chain stages', none: 'no chain links', }[model.source]; container.innerHTML = `
${findings.length} finding(s) · ${cols.length} stage(s) · ${edges.length} link(s)${inferredCount ? ` (${inferredCount} inferred)` : ''} · ${roots.length} entry point(s)
${esc(provenance)} — inferred links are hypotheses, drawn dashed.
${headSvg} ${rootEdges} ${edgeSvg} 🎯 target ${esc(trimMid(target || '', 14))} ${nodeSvg}
drag to pan · scroll to zoom · click a node for the finding
${['critical', 'high', 'medium', 'low', 'info'].map((s) => `${s}`).join('')} asserted chain inferred
`; const svg = container.querySelector('#apSvg'); const pan = container.querySelector('#apPan'); const wrap = container.querySelector('#apWrap'); const apply = () => pan.setAttribute('transform', `translate(${view.tx},${view.ty}) scale(${view.k})`); const fit = () => { const box = wrap.getBoundingClientRect(); // The panel is rendered while its tab is still hidden, so the box measures // zero and a "fit" there would lock in a garbage scale. Report the failure // so the caller can try again once the tab is actually on screen. if (!box.width || !box.height) return false; view.k = Math.min(box.width / width, box.height / height, 1); view.tx = (box.width - width * view.k) / 2; view.ty = (box.height - height * view.k) / 2; apply(); return true; }; apply(); // Auto-fit once, and only once it can actually measure: re-fitting on every // live finding would yank the canvas out from under someone mid-inspection. if (!view.fitted) { const tryFit = () => { if (fit()) view.fitted = true; }; requestAnimationFrame(tryFit); if (window.ResizeObserver) { const ro = new ResizeObserver(() => { if (view.fitted) ro.disconnect(); else tryFit(); }); ro.observe(wrap); } } container.querySelector('[data-ap-fit]').addEventListener('click', fit); container.querySelectorAll('[data-ap-zoom]').forEach((b) => b.addEventListener('click', () => { const box = wrap.getBoundingClientRect(); const factor = Number(b.dataset.apZoom) > 0 ? 1.2 : 1 / 1.2; const cx = box.width / 2, cy = box.height / 2; view.tx = cx - (cx - view.tx) * factor; view.ty = cy - (cy - view.ty) * factor; view.k = Math.max(0.15, Math.min(3, view.k * factor)); apply(); })); container.querySelector('#apHideInferred').addEventListener('change', (e) => { view.hideInferred = e.target.checked; renderAttackPath(container, allFindings, target, graph); }); container.querySelector('#apSev').addEventListener('change', (e) => { view.sev = e.target.value; renderAttackPath(container, allFindings, target, graph); }); wrap.addEventListener('wheel', (e) => { e.preventDefault(); const box = wrap.getBoundingClientRect(); const mx = e.clientX - box.left, my = e.clientY - box.top; const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; view.tx = mx - (mx - view.tx) * factor; view.ty = my - (my - view.ty) * factor; view.k = Math.max(0.15, Math.min(3, view.k * factor)); apply(); }, { passive: false }); let dragging = false, sx = 0, sy = 0, moved = 0; wrap.addEventListener('pointerdown', (e) => { dragging = true; moved = 0; sx = e.clientX - view.tx; sy = e.clientY - view.ty; wrap.setPointerCapture(e.pointerId); wrap.classList.add('dragging'); }); wrap.addEventListener('pointermove', (e) => { if (!dragging) return; moved += Math.abs(e.movementX) + Math.abs(e.movementY); view.tx = e.clientX - sx; view.ty = e.clientY - sy; apply(); }); const endDrag = (e) => { dragging = false; wrap.classList.remove('dragging'); if (e?.pointerId !== undefined) { try { wrap.releasePointerCapture(e.pointerId); } catch { /* already released */ } } }; wrap.addEventListener('pointerup', endDrag); wrap.addEventListener('pointercancel', endDrag); // Highlight the whole path through a node, both directions — the question a // reader has in front of a graph is "what led here, and where does it go". const up = new Map(), down = new Map(); edges.forEach((e) => { if (!down.has(e.from)) down.set(e.from, []); down.get(e.from).push(e.to); if (!up.has(e.to)) up.set(e.to, []); up.get(e.to).push(e.from); }); const reach = (start, map) => { const seen = new Set(), stack = [start]; while (stack.length) { const n = stack.pop(); for (const m of map.get(n) || []) if (!seen.has(m)) { seen.add(m); stack.push(m); } } return seen; }; const focusOn = (idx) => { const set = new Set([idx, ...reach(idx, up), ...reach(idx, down)]); svg.classList.add('has-focus'); container.querySelectorAll('.ap-node-g').forEach((g) => g.classList.toggle('focus', set.has(Number(g.dataset.idx)))); container.querySelectorAll('.ap-edge').forEach((p) => { const f = Number(p.dataset.from), t = Number(p.dataset.to); const r = Number(p.dataset.rootTo); p.classList.toggle('focus', (set.has(f) && set.has(t)) || (!Number.isNaN(r) && r === idx)); }); }; const clearFocus = () => { svg.classList.remove('has-focus'); container.querySelectorAll('.focus').forEach((el) => el.classList.remove('focus')); }; container.querySelectorAll('.ap-node-g').forEach((g) => { const idx = Number(g.dataset.idx); g.addEventListener('mouseenter', () => focusOn(idx)); g.addEventListener('focus', () => focusOn(idx)); g.addEventListener('mouseleave', clearFocus); g.addEventListener('blur', clearFocus); const open = () => { const isLive = container.id === 'liveAttackPath'; const pocs = isLive ? (state.currentJob?.pocs || []) : (state.detailPocs || []); const runId = isLive ? state.currentJob?.runId : state.currentDetailId; openFindingModal(findings[idx], pocs, runId); }; // A pan that ends on a node is not a click on it. g.addEventListener('click', () => { if (moved < 6) open(); }); g.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }); }); } /// 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) => `
  • ${esc(s)}
  • `).join(''); const payload = f.payload?.trim() ? `
    ${esc(f.payload.trim())}
    ` : ''; const cited = `${f.evidence || ''} ${f.payload || ''} ${(f.repro_steps || []).join(' ')}`; const scripts = (pocs || []).filter((p) => cited.includes(p)); const scriptBlock = scripts.length ? `
    The steps above are the proof; this script automates them.
    ${scripts.map((n) => `
    pocs/${esc(n)} Open raw
    `).join('')}
    ` : ''; return `
      ${steps}
    ${payload}${scriptBlock}`; } function trimMid(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; } // --------------------------------------------------------------------------- // sidebar — runs history // --------------------------------------------------------------------------- async function refreshRuns() { try { state.runs = await api('/api/runs'); } catch { state.runs = []; } renderSidebar(); } const PHASE_ORDER = { starting: 0, recon: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 }; function stepClassFor(phase, step) { const order = ['recon', 'planning', 'exploiting', 'remediation']; if (step === 'remediation') return 'pending'; // not automated yet const idx = PHASE_ORDER[phase] ?? 0; const stepIdx = order.indexOf(step); if (stepIdx < idx) return 'done'; if (stepIdx === idx) return 'active'; return 'pending'; } /// Group runs into target folders. Twelve runs of three hosts was a flat list /// of twelve near-identical rows; the host is what an operator actually scans /// for, so it becomes the folder and the runs live inside it. function runFolders(runs) { const folders = new Map(); for (const r of runs) { const key = engagementKey(r.target || r.id); if (!folders.has(key)) folders.set(key, { key, items: [], ts: 0, findings: 0, severities: {} }); const f = folders.get(key); f.items.push(r); f.ts = Math.max(f.ts, r.ts || 0); f.findings += r.findings || 0; for (const [k, n] of Object.entries(r.severities || {})) f.severities[k] = (f.severities[k] || 0) + n; } for (const f of folders.values()) f.items.sort((a, b) => (b.ts || 0) - (a.ts || 0)); return [...folders.values()].sort((a, b) => b.ts - a.ts); } /// Same normalization the harness uses for its engagement key, so a folder here /// and an engagement in the harness's memory mean the same thing. function engagementKey(target) { const t = String(target || '').trim().toLowerCase(); const noScheme = t.includes('://') ? t.split('://')[1] : t; const host = noScheme.split(/[/?#]/)[0].replace(/:\d+$/, '').replace(/^www\./, ''); return host || t || 'unknown'; } function worstSeverity(severities) { return SEV_ORDER.find((s) => Object.entries(severities || {}).some(([k, n]) => n && SEV_ORDER[sevRank(k)] === s)); } const SB_OPEN_KEY = 'ns-sb-open'; function openFolders() { try { return new Set(JSON.parse(localStorage.getItem(SB_OPEN_KEY) || '[]')); } catch { return new Set(); } } function setFolderOpen(key, on) { const s = openFolders(); if (on) s.add(key); else s.delete(key); localStorage.setItem(SB_OPEN_KEY, JSON.stringify([...s])); } function runButton(r) { const btn = document.createElement('button'); btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : ''); // Every line here truncates: a long target URL used to run past the // sidebar's edge and collide with the main pane. const worst = worstSeverity(r.severities); btn.innerHTML = ` ${worst ? `` : ''}${esc(r.name || r.target)} ${r.name ? esc(r.target) : esc(r.id)} ${r.findings} finding${r.findings === 1 ? '' : 's'}${esc(timeAgo(r.ts))}`; btn.title = `${r.name ? r.name + '\n' : ''}${r.target}\n${r.id}${r.ts ? '\n' + new Date(r.ts * 1000).toLocaleString() : ''}`; btn.addEventListener('click', () => openRun(r)); return btn; } function renderSidebar() { const root = $('#sbGroups'); root.innerHTML = ''; const q = (state.runFilter || '').trim().toLowerCase(); const match = (r) => !q || `${r.name} ${r.target} ${r.id}`.toLowerCase().includes(q); const runs = state.runs.filter(match); const running = runs.filter((r) => r.state === 'running'); const past = runs.filter((r) => r.state !== 'running'); if (running.length) { const wrap = document.createElement('div'); wrap.className = 'sb-group'; wrap.innerHTML = `
    ▾Running${running.length}
    `; wrap.querySelector('.sb-group-head').addEventListener('click', () => wrap.classList.toggle('collapsed')); const items = wrap.querySelector('.sb-items'); for (const r of running) { items.appendChild(runButton(r)); if (state.currentJob && r.id === state.currentJob.runId) { const steps = document.createElement('div'); steps.className = 'sb-steps'; steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) => `
    ${s[0].toUpperCase() + s.slice(1)}
    `).join(''); items.appendChild(steps); } } root.appendChild(wrap); } const folders = runFolders(past); if (!folders.length) { const empty = document.createElement('div'); empty.className = 'sb-empty'; empty.textContent = q ? `No run matches “${q}”.` : 'No runs yet.'; root.appendChild(empty); return; } const open = openFolders(); for (const f of folders) { const holdsActive = f.items.some((r) => r.id === state.currentDetailId); // A search is a request to see what matched — collapsing the results would // hide the very thing that was searched for. const isOpen = !!q || holdsActive || open.has(f.key); const wrap = document.createElement('div'); wrap.className = 'sb-folder' + (isOpen ? '' : ' collapsed'); const worst = worstSeverity(f.severities); wrap.innerHTML = `
    ▾ ${worst ? `` : ''} ${esc(f.key)} ${f.items.length}
    `; 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); } } 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; } show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true); loadDetail(run.id); renderSidebar(); } async function loadDetail(id) { clearInterval(state.detailPoll); 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)}`); 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(''); // The harness writes graph.json per run (knowledge_graph::ingest). When it // exists, its edges — including which ones it inferred — beat anything the // browser could re-derive; older runs simply fall back. const graph = await api(`/api/runs/${encodeURIComponent(id)}/asset/graph.json`).catch(() => null); renderAttackPath($('#detailAttackPath'), detail.findings, target, graph); 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); 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(''); $$('input[data-f]', root).forEach((inp) => inp.addEventListener('input', (e) => { state.auth.roles[Number(e.target.dataset.i)][e.target.dataset.f] = e.target.value; })); $$('[data-remove]', root).forEach((btn) => btn.addEventListener('click', () => { state.auth.roles.splice(Number(btn.dataset.i), 1); renderRoleList(); })); } $('#btnAddRole').addEventListener('click', () => { state.auth.roles.push({ name: '', header: '' }); renderRoleList(); }); async function refreshKeyStatus() { try { state.keys = await api('/api/keys'); } catch { state.keys = []; } const root = $('#providerKeyList'); root.innerHTML = state.providers.map((p) => { const set = state.keys.find((k) => k.provider === p.key)?.set; return `
    ${esc(p.label)} ${p.kind === 'cli' ? 'cli+api' : 'api only'}
    `; }).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 = `
    Couldn't load stats: ${esc(e.message)}
    `; return; } const cutoff = dashRangeCutoff(); const runs = data.runs.filter((r) => !cutoff || r.ts >= cutoff); const findings = data.findings.filter((f) => !cutoff || f.ts >= cutoff); const params = fairParams(); if (!runs.length) { 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 }) => `
    ${esc(f.severity)} ${esc(f.title || f.cwe || 'untitled')} ${money(ml)}
    `).join('') || '
    No findings in range.
    '}

    Findings by severity

    ${SEV_ORDER.filter((s) => sevCounts[s]).map((s) => barRow(s, sevCounts[s], maxSev, s, `${sevCounts[s]} ${s} finding(s)`)).join('') || '
    No findings in range.
    '}

    Most frequent weaknesses

    ${topCwe.map(([cwe, n]) => barRow(cwe, n, maxCwe, 'neutral', `${cwe}: ${n} finding(s)`)).join('') || '
    No CWE data in range.
    '}

    Targets

    ${targetRows.map(([k, v]) => ``).join('')}
    TargetRunsFindingsLast tested
    ${esc(k)} ${v.runs} ${v.findings} ${esc(timeAgo(v.last))}
    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(); })); } /// 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 = ` `; 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 setInterval(refreshRuns, 6000); } boot();