mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-26 03:12:30 +02:00
feat(web): engagement wizard, model/auth picker, Auth & Keys menu, attack-path graph
Full frontend rewrite following a deliberate visual direction (dense security-operations console — borders over shadows, two radii, one accent, no gradients/glassmorphism) and fixing real bugs found in review: - EventSource on the exploit stream never called es.close() on 'done', so the browser silently reconnected and re-streamed the whole job (duplicate log lines/findings). Fixed. - Sidebar 'running' step indicator and openRun() matched ANY running run instead of the one belonging to the current job (by runId). Fixed. New: - 5-step engagement wizard (Asset -> Scope & Auth -> Leads -> Model & Run -> Review) replacing the single flat board — inspired by the Discovery/Plan/Exploit/Remediate stage model both a.security and terra.security use publicly. - Model is now a real dropdown sourced from /api/providers (mirrors harness::models::providers()), with an API-key vs. subscription toggle that disables subscription for API-only providers. - One Auth & Keys menu: target auth header + named roles (IDOR/BOLA/BFLA multi-identity testing) materialize into an ephemeral creds.yaml passed via --creds; per-provider API keys live in server memory only (never on disk) and are merged into every spawned child's env. - Generative Attack Path Chaining: findings rendered as kill-chain columns (recon -> initial-access -> ... -> impact) with chains_from resolved to parent titles, live in the run view and static in run detail. - Findings are now a proper table (severity/title/endpoint/CWE/agent/ confidence) instead of stacked cards. - Explicit light/dark theme toggle persisted in localStorage, defaulting to light (previously light only won when the OS wasn't in dark mode). - All UI strings in English. Backend additions: GET /api/providers, GET/POST/DELETE /api/keys, ephemeral creds.yaml generation for auth/roles, env override merged into every exploit-job and REPL child spawn. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d1d1c71e24
commit
bb659412fc
+408
-147
@@ -1,20 +1,36 @@
|
||||
'use strict';
|
||||
/* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
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 = {
|
||||
categories: [], // from /api/agents
|
||||
selected: new Set(), // agent ids toggled on
|
||||
customLeads: [], // free-text custom leads (folded into --focus)
|
||||
filter: 'all', // all | selected | excluded
|
||||
theme: localStorage.getItem('ns-theme') || 'light',
|
||||
step: 0,
|
||||
mode: 'run',
|
||||
categories: [],
|
||||
selected: new Set(),
|
||||
customLeads: [],
|
||||
filter: 'all',
|
||||
search: '',
|
||||
runs: [], // from /api/runs
|
||||
currentJob: null, // {id, es} for the live view
|
||||
currentDetailId: null, // run id shown in detail view
|
||||
providers: [],
|
||||
auth: { header: '', roles: [] },
|
||||
credsPath: '',
|
||||
keys: [],
|
||||
runs: [],
|
||||
currentJob: null,
|
||||
currentDetailId: null,
|
||||
detailPoll: null,
|
||||
askFocus: '', askObjective: '', askOutOfScope: '',
|
||||
replId: null, replEs: null,
|
||||
};
|
||||
|
||||
@@ -27,21 +43,96 @@ function esc(s) {
|
||||
}
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||
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 sevClass(sev) {
|
||||
function sevRank(sev) {
|
||||
const s = (sev || '').toLowerCase();
|
||||
if (s.includes('crit')) return 'sev-critical';
|
||||
if (s.includes('high')) return 'sev-high';
|
||||
if (s.includes('med')) return 'sev-medium';
|
||||
if (s.includes('low')) return 'sev-low';
|
||||
return 'sev-info';
|
||||
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 show(el, on) { el.hidden = !on; }
|
||||
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; }
|
||||
function toast(msg) { console.log('[ns]', msg); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agents / lead board
|
||||
// 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';
|
||||
}
|
||||
$('#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();
|
||||
}
|
||||
|
||||
function validateStep(n) {
|
||||
if (n === 0) {
|
||||
const target = $('#fieldTarget').value.trim();
|
||||
if (!target) { alert(`${MODE_LABELS[state.mode].target} is required.`); return false; }
|
||||
if (state.mode === 'greybox' && !$('#fieldRepo').value.trim()) { alert('Source repo is required for grey-box.'); 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 target = $('#fieldTarget').value.trim() || '(not set)';
|
||||
$('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · <b>${esc(state.mode)}</b> · <b>${esc(target)}</b>`;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -57,7 +148,6 @@ function renderBoard() {
|
||||
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'cat-card';
|
||||
card.dataset.category = group.category;
|
||||
card.innerHTML = `
|
||||
<div class="cat-head">
|
||||
<label class="switch">
|
||||
@@ -92,19 +182,14 @@ function renderBoard() {
|
||||
});
|
||||
card.querySelector('.cat-toggle').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);
|
||||
}
|
||||
for (const a of group.agents) { if (on) state.selected.add(a.id); else state.selected.delete(a.id); }
|
||||
renderBoard();
|
||||
updateChips();
|
||||
});
|
||||
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();
|
||||
updateChips();
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
root.appendChild(card);
|
||||
@@ -113,9 +198,7 @@ function renderBoard() {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function allAgents() {
|
||||
return state.categories.flatMap((g) => g.agents);
|
||||
}
|
||||
function allAgents() { return state.categories.flatMap((g) => g.agents); }
|
||||
|
||||
function updateChips() {
|
||||
const total = allAgents().length;
|
||||
@@ -127,8 +210,7 @@ function updateChips() {
|
||||
function applyFilters() {
|
||||
const q = state.search.trim().toLowerCase();
|
||||
$$('.agent-row').forEach((row) => {
|
||||
const id = row.dataset.id;
|
||||
const isSel = state.selected.has(id);
|
||||
const isSel = state.selected.has(row.dataset.id);
|
||||
let visible = true;
|
||||
if (state.filter === 'selected') visible = isSel;
|
||||
if (state.filter === 'excluded') visible = !isSel;
|
||||
@@ -136,108 +218,170 @@ function applyFilters() {
|
||||
row.classList.toggle('hidden-by-search', !visible);
|
||||
});
|
||||
$$('.cat-card').forEach((card) => {
|
||||
const anyVisible = [...card.querySelectorAll('.agent-row')].some((r) => !r.classList.contains('hidden-by-search'));
|
||||
const anyVisible = $$('.agent-row', card).some((r) => !r.classList.contains('hidden-by-search'));
|
||||
card.style.display = anyVisible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// engagement bar / ask panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function currentMode() { return $('#fieldMode').value; }
|
||||
|
||||
$('#fieldMode').addEventListener('change', () => {
|
||||
show($('.eb-repo'), currentMode() === 'greybox');
|
||||
$('.eb-target label').textContent = currentMode() === 'whitebox' ? 'Repo / path' : 'Target';
|
||||
});
|
||||
|
||||
$('#btnAskSend').addEventListener('click', () => {
|
||||
const kind = $('#askKind').value;
|
||||
const text = $('#askInput').value.trim();
|
||||
if (!text) return;
|
||||
if (kind === 'focus') state.askFocus = text;
|
||||
if (kind === 'objective') state.askObjective = text;
|
||||
if (kind === 'scope-out') state.askOutOfScope = text;
|
||||
$('#askHint').textContent = `✓ ${kind} definido — aplicado no próximo "Start Exploitation"`;
|
||||
$('#askInput').value = '';
|
||||
});
|
||||
|
||||
$('#btnCustomLead').addEventListener('click', () => {
|
||||
const text = prompt('Descreva a lead customizada (linguagem livre — vira contexto de foco para os agentes):');
|
||||
if (text && text.trim()) {
|
||||
state.customLeads.push(text.trim());
|
||||
$('#askHint').textContent = `✓ custom lead adicionada (${state.customLeads.length} total)`;
|
||||
}
|
||||
});
|
||||
|
||||
$$('.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();
|
||||
});
|
||||
});
|
||||
|
||||
$$('.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) => `
|
||||
<div class="custom-lead-chip"><span>${esc(text)}</span><span class="x" data-i="${i}">✕</span></div>
|
||||
`).join('');
|
||||
$$('.custom-lead-chip .x', root).forEach((x) => x.addEventListener('click', () => {
|
||||
state.customLeads.splice(Number(x.dataset.i), 1);
|
||||
renderCustomLeads();
|
||||
}));
|
||||
}
|
||||
$('#btnCustomLead').addEventListener('click', () => {
|
||||
const text = prompt('Describe the custom lead (free text — becomes agent focus context):');
|
||||
if (text && text.trim()) { state.customLeads.push(text.trim()); renderCustomLeads(); }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// start exploitation → live run view
|
||||
// providers / model (step 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$('#btnStartExploitation').addEventListener('click', startExploitation);
|
||||
async function loadProviders() {
|
||||
state.providers = await api('/api/providers');
|
||||
const sel = $('#fieldProvider');
|
||||
sel.innerHTML = state.providers.map((p) => `<option value="${esc(p.key)}">${esc(p.label)} (${p.kind === 'cli' ? 'API or subscription' : 'API key only'})</option>`).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) => `<option value="${esc(m)}">${esc(m)}</option>`).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';
|
||||
|
||||
async function startExploitation() {
|
||||
const mode = currentMode();
|
||||
// ---------------------------------------------------------------------------
|
||||
// review (step 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderReview() {
|
||||
const target = $('#fieldTarget').value.trim();
|
||||
const repo = $('#fieldRepo').value.trim();
|
||||
if (mode !== 'whitebox' && !target) { alert('Defina o target.'); return; }
|
||||
if (mode === 'whitebox' && !target && !repo) { alert('Defina o repo/path.'); return; }
|
||||
if (mode === 'greybox' && !repo) { alert('Grey-box precisa de repo + target.'); return; }
|
||||
const provider = $('#fieldProvider').value;
|
||||
const model = $('#fieldModelSelect').value;
|
||||
const items = [
|
||||
{ k: 'Mode', v: state.mode },
|
||||
{ k: MODE_LABELS[state.mode].target, v: target || '(not set)', mono: true },
|
||||
...(MODE_LABELS[state.mode].showRepo ? [{ k: 'Source repo', v: repo || '(not set)', mono: true }] : []),
|
||||
{ k: 'Model', v: `${provider}:${model}` },
|
||||
{ k: 'Auth mode', v: state.authMode === 'subscription' ? 'Subscription (local CLI)' : 'API key' },
|
||||
{ k: 'Leads selected', v: `${state.selected.size} of ${allAgents().length}${state.selected.size === 0 ? ' — auto (recon-driven)' : ''}` },
|
||||
{ k: 'Custom leads', v: String(state.customLeads.length) },
|
||||
{ k: 'Votes / chain / recon', v: `${$('#fieldVotes').value} / ${$('#fieldChain').value} / ${$('#fieldRecon').value}` },
|
||||
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
|
||||
];
|
||||
$('#reviewGrid').innerHTML = items.map((it) => `
|
||||
<div class="review-item"><div class="k">${esc(it.k)}</div><div class="v${it.mono ? ' mono' : ''}">${esc(it.v)}</div></div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const modelField = $('#fieldModel').value.trim();
|
||||
const focusParts = [state.askFocus, ...state.customLeads].filter(Boolean);
|
||||
// ---------------------------------------------------------------------------
|
||||
// launch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$('#btnLaunch').addEventListener('click', startExploitation);
|
||||
|
||||
async function startExploitation() {
|
||||
const mode = state.mode;
|
||||
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,
|
||||
target: mode === 'whitebox' ? undefined : target,
|
||||
repo: mode === 'whitebox' ? (target || repo) : (repo || undefined),
|
||||
models: modelField ? [modelField] : [],
|
||||
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: $('#fieldSubscription').checked,
|
||||
subscription: state.authMode === 'subscription',
|
||||
mcp: $('#fieldMcp').checked,
|
||||
agents: [...state.selected],
|
||||
focus: focusParts.join('; ') || undefined,
|
||||
objective: state.askObjective || undefined,
|
||||
outOfScope: state.askOutOfScope || undefined,
|
||||
objective: $('#fieldObjective').value.trim() || undefined,
|
||||
outOfScope: $('#fieldOutOfScope').value.trim() || undefined,
|
||||
auth: state.auth.header || undefined,
|
||||
roles: state.auth.roles.length ? state.auth.roles : undefined,
|
||||
creds: state.credsPath || undefined,
|
||||
};
|
||||
|
||||
$('#btnStartExploitation').disabled = true;
|
||||
$('#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);
|
||||
} catch (e) {
|
||||
alert('Falha ao iniciar: ' + e.message);
|
||||
alert('Failed to start: ' + e.message);
|
||||
} finally {
|
||||
$('#btnStartExploitation').disabled = false;
|
||||
$('#btnLaunch').disabled = false;
|
||||
$('#btnLaunch').textContent = 'Start Exploitation →';
|
||||
}
|
||||
}
|
||||
|
||||
function attachLiveJob(id, target) {
|
||||
if (state.currentJob) state.currentJob.es.close();
|
||||
state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null };
|
||||
// ---------------------------------------------------------------------------
|
||||
// live run view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
show($('#boardView'), false);
|
||||
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'));
|
||||
|
||||
function attachLiveJob(id, target) {
|
||||
if (state.currentJob?.es) state.currentJob.es.close();
|
||||
state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null, runId: null };
|
||||
|
||||
show($('#wizardView'), false);
|
||||
show($('#detailView'), false);
|
||||
show($('#liveView'), true);
|
||||
$('#liveTarget').textContent = target || '—';
|
||||
$('#livePhase').textContent = 'starting';
|
||||
$('#findingsList').innerHTML = '';
|
||||
$('#phaseDot').style.background = '';
|
||||
$('#liveFindingsTable tbody').innerHTML = '';
|
||||
$('#liveAttackPath').innerHTML = '';
|
||||
$('#logList').innerHTML = '';
|
||||
$('#findingsCount').textContent = '0';
|
||||
$('#liveFindingsCount').textContent = '0';
|
||||
show($('#liveFindingsEmpty'), true);
|
||||
$('#progressFill').style.width = '0%';
|
||||
$('#progressLabel').textContent = '0 / 0 agents';
|
||||
show($('#btnOpenReport'), false);
|
||||
@@ -247,8 +391,8 @@ function attachLiveJob(id, target) {
|
||||
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)); refreshRuns(); });
|
||||
es.onerror = () => { /* browser auto-retries; fine for a long-running engagement */ };
|
||||
es.addEventListener('done', (e) => { applySnapshot(JSON.parse(e.data)); es.close(); refreshRuns(); });
|
||||
es.onerror = () => { /* EventSource auto-retries; the server replays its buffer on reconnect */ };
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
@@ -260,52 +404,106 @@ function appendLog(line) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
function findingRow(f) {
|
||||
return `<tr>
|
||||
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
||||
<td>${esc(f.title)}</td>
|
||||
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
|
||||
<td>${esc(f.cwe)}</td>
|
||||
<td>${esc(f.agent)}</td>
|
||||
<td class="col-conf">${f.confidence ? f.confidence.toFixed(2) : '—'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function addFinding(f) {
|
||||
state.currentJob.findings.push(f);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'finding-card';
|
||||
card.innerHTML = `
|
||||
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
|
||||
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
|
||||
`;
|
||||
$('#findingsList').appendChild(card);
|
||||
$('#findingsCount').textContent = state.currentJob.findings.length;
|
||||
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f));
|
||||
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
|
||||
show($('#liveFindingsEmpty'), false);
|
||||
renderAttackPath($('#liveAttackPath'), state.currentJob.findings);
|
||||
}
|
||||
|
||||
function applySnapshot(snap) {
|
||||
$('#livePhase').textContent = snap.phase;
|
||||
state.currentJob.runId = snap.runId;
|
||||
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
|
||||
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
|
||||
if (snap.reportUrl) {
|
||||
const link = $('#btnOpenReport');
|
||||
link.href = `/api/runs/${snap.runId}/asset/report.html`;
|
||||
show(link, !!snap.runId);
|
||||
if (snap.reportUrl && snap.runId) {
|
||||
$('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`;
|
||||
show($('#btnOpenReport'), true);
|
||||
}
|
||||
if (snap.done) $('#phaseDot').style.background = 'var(--green)';
|
||||
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' });
|
||||
});
|
||||
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#boardView'), true); });
|
||||
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#wizardView'), true); });
|
||||
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#wizardView'), true); });
|
||||
$('#btnNewEngagement').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#wizardView'), true); });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generative Attack Path Chaining
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KILL_CHAIN_STAGES = ['recon', 'initial-access', 'execution', 'privesc', 'lateral', 'exfil', 'impact'];
|
||||
|
||||
function renderAttackPath(container, findings) {
|
||||
if (!findings.length) {
|
||||
container.innerHTML = '<div class="attackpath-empty">The attack path builds automatically as findings chain together — nothing confirmed yet.</div>';
|
||||
return;
|
||||
}
|
||||
const byId = new Map(findings.map((f) => [f.id, f]));
|
||||
const hasStages = findings.some((f) => f.stage);
|
||||
let groups;
|
||||
if (hasStages) {
|
||||
groups = KILL_CHAIN_STAGES
|
||||
.map((stage) => ({ label: stage.replace('-', ' '), items: findings.filter((f) => (f.stage || '') === stage) }))
|
||||
.filter((g) => g.items.length);
|
||||
const other = findings.filter((f) => !f.stage);
|
||||
if (other.length) groups.push({ label: 'unstaged', items: other });
|
||||
} else {
|
||||
const order = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
groups = order
|
||||
.map((sev) => ({ label: sev, items: findings.filter((f) => (f.severity || '').toLowerCase().includes(sev)) }))
|
||||
.filter((g) => g.items.length);
|
||||
}
|
||||
container.innerHTML = `
|
||||
${!hasStages ? '<div class="field-help" style="margin-bottom:8px;">No kill-chain stage data yet — grouped by severity.</div>' : ''}
|
||||
<div class="attackpath">
|
||||
${groups.map((g, i) => `
|
||||
${i > 0 ? '<div class="ap-arrow">→</div>' : ''}
|
||||
<div class="ap-stage">
|
||||
<div class="ap-stage-head">${esc(g.label)} (${g.items.length})</div>
|
||||
${g.items.map((f) => `
|
||||
<div class="ap-node ${sevClass(f.severity)}">
|
||||
<div class="t">${esc(f.title)}</div>
|
||||
<div class="m">${esc(f.mitre || f.owasp || f.cwe || '')}</div>
|
||||
${(f.chains_from || []).length ? `<div class="chain-from">⤷ chains from ${(f.chains_from).map((cid) => esc(byId.get(cid)?.title || cid)).join(', ')}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sidebar — runs history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function refreshRuns() {
|
||||
try {
|
||||
state.runs = await api('/api/runs');
|
||||
} catch { state.runs = []; }
|
||||
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'];
|
||||
const idx = { recon: 0, starting: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 }[phase] ?? 0;
|
||||
if (step === 'remediation') return 'pending'; // not automated yet
|
||||
const idx = PHASE_ORDER[phase] ?? 0;
|
||||
const stepIdx = order.indexOf(step);
|
||||
if (step === 'remediation') return 'pending'; // not automated yet — shown for roadmap parity only
|
||||
if (stepIdx < idx) return 'done';
|
||||
if (stepIdx === idx) return 'active';
|
||||
return 'pending';
|
||||
@@ -314,14 +512,9 @@ function stepClassFor(phase, step) {
|
||||
function renderSidebar() {
|
||||
const root = $('#sbGroups');
|
||||
root.innerHTML = '';
|
||||
|
||||
const running = state.runs.filter((r) => r.state === 'running');
|
||||
const completed = state.runs.filter((r) => r.state !== 'running');
|
||||
|
||||
const groups = [
|
||||
{ label: 'Running', items: running, open: true },
|
||||
{ label: 'Completed', items: completed, open: true },
|
||||
];
|
||||
const groups = [{ label: 'Running', items: running }, { label: 'Completed', items: completed }];
|
||||
|
||||
for (const g of groups) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -332,15 +525,15 @@ function renderSidebar() {
|
||||
for (const r of g.items) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
||||
btn.innerHTML = `${esc(r.target)}<span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
|
||||
btn.innerHTML = `<span class="name">${esc(r.target)}</span><span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
|
||||
btn.addEventListener('click', () => openRun(r));
|
||||
items.appendChild(btn);
|
||||
if (r.state === 'running' && state.currentJob) {
|
||||
const phase = state.currentJob.target === r.target ? $('#livePhase').textContent : null;
|
||||
const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId;
|
||||
if (isThisJob) {
|
||||
const steps = document.createElement('div');
|
||||
steps.className = 'sb-steps';
|
||||
steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) =>
|
||||
`<div class="sb-step ${stepClassFor(phase || 'recon', s)}">${s[0].toUpperCase() + s.slice(1)}</div>`).join('');
|
||||
`<div class="sb-step ${stepClassFor($('#livePhase').textContent, s)}">${s[0].toUpperCase() + s.slice(1)}</div>`).join('');
|
||||
items.appendChild(steps);
|
||||
}
|
||||
}
|
||||
@@ -350,11 +543,12 @@ function renderSidebar() {
|
||||
|
||||
function openRun(run) {
|
||||
state.currentDetailId = run.id;
|
||||
if (run.state === 'running' && state.currentJob) {
|
||||
show($('#boardView'), false); show($('#detailView'), false); show($('#liveView'), true);
|
||||
if (run.state === 'running' && state.currentJob && run.id === state.currentJob.runId) {
|
||||
show($('#wizardView'), false); show($('#detailView'), false); show($('#liveView'), true);
|
||||
renderSidebar();
|
||||
return;
|
||||
}
|
||||
show($('#boardView'), false); show($('#liveView'), false); show($('#detailView'), true);
|
||||
show($('#wizardView'), false); show($('#liveView'), false); show($('#detailView'), true);
|
||||
loadDetail(run.id);
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -364,27 +558,87 @@ async function loadDetail(id) {
|
||||
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
|
||||
$('#detailTarget').textContent = detail.status?.target || detail.meta?.target || id;
|
||||
$('#detailState').textContent = detail.status?.state || 'unknown';
|
||||
const list = $('#detailFindings');
|
||||
list.innerHTML = detail.findings.length
|
||||
? detail.findings.map((f) => `
|
||||
<div class="finding-card">
|
||||
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
|
||||
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
|
||||
</div>`).join('')
|
||||
: '<div class="f-meta">nenhum finding validado.</div>';
|
||||
$('#detailFindingsCount').textContent = detail.findings.length;
|
||||
const tbody = $('#detailFindingsTable tbody');
|
||||
tbody.innerHTML = detail.findings.map(findingRow).join('');
|
||||
show($('#detailFindingsEmpty'), detail.findings.length === 0);
|
||||
renderAttackPath($('#detailAttackPath'), detail.findings);
|
||||
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);
|
||||
|
||||
if (detail.status?.state === 'running') {
|
||||
state.detailPoll = setInterval(() => loadDetail(id), 4000);
|
||||
}
|
||||
if (detail.status?.state === 'running') state.detailPoll = setInterval(() => loadDetail(id), 4000);
|
||||
}
|
||||
|
||||
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#boardView'), true); });
|
||||
$('#btnNewEngagement').addEventListener('click', () => { show($('#detailView'), false); show($('#liveView'), false); show($('#boardView'), true); });
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(); });
|
||||
|
||||
function renderRoleList() {
|
||||
const root = $('#roleList');
|
||||
root.innerHTML = state.auth.roles.map((r, i) => `
|
||||
<div class="role-row">
|
||||
<input class="role-name" data-i="${i}" data-f="name" placeholder="role name" value="${esc(r.name)}" />
|
||||
<input data-i="${i}" data-f="header" placeholder="Authorization: Bearer ..." value="${esc(r.header)}" />
|
||||
<button class="icon-btn" data-i="${i}" data-remove>✕</button>
|
||||
</div>
|
||||
`).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 `
|
||||
<div class="provider-row">
|
||||
<span class="dot ${set ? 'set' : ''}"></span>
|
||||
<span class="p-name">${esc(p.label)}</span>
|
||||
<span class="p-kind">${p.kind === 'cli' ? 'cli+api' : 'api only'}</span>
|
||||
<input type="password" data-provider="${esc(p.key)}" placeholder="${set ? '•••••••• (set — enter to replace)' : 'paste API key'}" />
|
||||
<button class="btn btn-sm" data-save="${esc(p.key)}">Save</button>
|
||||
</div>
|
||||
`;
|
||||
}).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();
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REPL drawer — real CLI harness session
|
||||
@@ -401,10 +655,10 @@ async function startRepl() {
|
||||
es.addEventListener('data', (e) => {
|
||||
const { chunk } = JSON.parse(e.data);
|
||||
const out = $('#replOutput');
|
||||
out.textContent += chunk;
|
||||
out.appendChild(document.createTextNode(chunk));
|
||||
out.scrollTop = out.scrollHeight;
|
||||
});
|
||||
es.addEventListener('close', () => { es.close(); });
|
||||
es.addEventListener('close', () => es.close());
|
||||
}
|
||||
|
||||
$('#fabRepl').addEventListener('click', openReplDrawer);
|
||||
@@ -421,7 +675,10 @@ $('#replInput').addEventListener('keydown', async (e) => {
|
||||
const line = e.target.value;
|
||||
e.target.value = '';
|
||||
const out = $('#replOutput');
|
||||
out.textContent += `❭ ${line}\n`;
|
||||
const echo = document.createElement('span');
|
||||
echo.className = 'repl-echo';
|
||||
echo.textContent = `❭ ${line}\n`;
|
||||
out.appendChild(echo);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
if (!state.replId) await startRepl();
|
||||
await api(`/api/repl/${state.replId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }) });
|
||||
@@ -432,9 +689,13 @@ $('#replInput').addEventListener('keydown', async (e) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
applyTheme();
|
||||
selectMode('run');
|
||||
goToStep(0);
|
||||
renderCustomLeads();
|
||||
const meta = await api('/api/meta').catch(() => ({}));
|
||||
$('#sbMeta').textContent = `v${meta.version || '4.0.0'}`;
|
||||
await loadAgents();
|
||||
$('#sbVersion').textContent = `v${meta.version || '4.0.0'}`;
|
||||
await Promise.all([loadAgents(), loadProviders()]);
|
||||
await refreshRuns();
|
||||
setInterval(refreshRuns, 6000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user