'use strict'; /* NeuroSploit v4.2.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(text)}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 = '
${esc(s)}${esc(f.payload.trim())}| Target | Runs | Findings | Last tested |
|---|---|---|---|
| ${esc(k)} | ${v.runs} | ${v.findings} | ${esc(timeAgo(v.last))} |