mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 04:21:44 +02:00
feat(web): xterm.js terminal dock + front-end QA pass
Replaces the floating REPL drawer with a docked terminal, and fixes the usability problems a screenshot audit of the console turned up. Terminal (the reason for the change): - The drawer rendered the harness into a <div>, so the server had to strip ANSI before sending it: colour, the box-drawn /status panel and the banner all arrived flattened, and long lines rewrapped mid-glyph. The stream is now sent verbatim and rendered by xterm.js (vendored, nothing fetched at runtime), decoded with a streaming UTF-8 decoder so a multi-byte character split across two reads survives. - The drawer floated bottom-right, directly over "Next →" and "Start Exploitation" — the wizard's primary buttons. The dock is a flex child of .main, so opening it shortens the view instead of covering it. Drag its top edge to resize; the height is remembered. - The child is spawned over a pipe, not a PTY, so it never echoes: line editing is local — echo, ←/→, Home/End, history, Tab completion over the slash commands, Ctrl+C/L/U/K/A/E. Ctrl-C is delivered as SIGINT by the server, since a raw 0x03 byte over a pipe interrupts nothing. - A target picker switches the terminal between a standalone REPL session and the engagement currently running, so mid-run instructions go to the same process doing the testing. QA fixes: - Findings tables sorted by severity (a LOW above a CRITICAL made a 27-row result unreadable), with sortable headers, a severity summary that doubles as a filter, a text filter, a sticky header, and horizontal scroll confined to the table instead of the whole page. - alert()/prompt() replaced by inline field errors, a custom-lead modal and toasts — a modal alert hid the very field it was complaining about. - Lead categories start collapsed (412 leads over ~30 categories); search auto-expands what it matches and shows per-category hit counts. - Sidebar rows truncate inside the rail (a long target URL used to spill past its border), and carry a worst-severity dot, finding count and age. - Past-run header shows when it ran, how many agents ran, the recon asset, PoC count and run id — two runs of one target were indistinguishable. - Off-canvas sidebar below 768px had no way to be opened; added the toggle. - Long evidence values (cookies, tokens) now wrap instead of running under the finding modal's edge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdGy9XtVWSdXDTa3FFLJv
This commit is contained in:
co-authored by
Claude Opus 5
parent
cea73a563a
commit
0ef0ce8d94
@@ -250,6 +250,10 @@ Zero npm dependencies (Node built-ins only).
|
||||
log tab grows a prompt box (`❭`) to send `/status`, `/stop`, `/continue`, or a plain-language
|
||||
instruction mid-run — same REPL described in [§6](TUTORIAL.md#6-the-interactive-repl). `host` /
|
||||
`aitest` / `skills` stay one-shot (their onboarding menu can't be scripted over piped stdin).
|
||||
- **Terminal dock** — `Ctrl+\`` (or `❭_` in the sidebar) opens a real terminal, xterm.js over an
|
||||
unstripped stdout stream, so the harness renders with its own colour and panels. Its header
|
||||
switches the terminal between a standalone REPL session and the engagement currently running,
|
||||
with local line editing: history, `Tab` completion over the slash commands, `Ctrl+C`/`L`/`U`.
|
||||
- **Auth & Keys** (one menu) — target auth header + named roles for IDOR/BOLA/BFLA testing
|
||||
(materializes an ephemeral `creds.yaml` for the run), and per-provider API keys held in the
|
||||
server process's memory only — never written to disk.
|
||||
|
||||
+9
-4
@@ -233,7 +233,11 @@ Starts a session. Response: `{ "id": "<session-uuid>" }`.
|
||||
|
||||
### `POST /api/repl/:id/input`
|
||||
|
||||
Body: `{ "line": "/status" }`. Writes `line + "\n"` to the child's stdin.
|
||||
Body: `{ "line": "/status" }` — writes `line + "\n"` to the child's stdin — or `{ "data": "..." }`
|
||||
to write bytes verbatim (what the browser terminal sends, newline included). A `data` payload
|
||||
containing `\u0003` (Ctrl-C) is delivered as `SIGINT` to the child instead of being written:
|
||||
without a tty in between, nothing else turns that byte into an interrupt. Responds `409` once the
|
||||
session's stdin has closed.
|
||||
|
||||
### `POST /api/repl/:id/stop`
|
||||
|
||||
@@ -243,11 +247,12 @@ Sends `SIGTERM` to the session's child process.
|
||||
|
||||
| event | data | meaning |
|
||||
|---------|-------------------------|---------|
|
||||
| `data` | `{ "chunk": "..." }` | raw stdout/stderr chunk (ANSI stripped), not line-buffered |
|
||||
| `data` | `{ "chunk": "..." }` | raw stdout/stderr chunk, ANSI **preserved**, not line-buffered |
|
||||
| `close` | `{}` | child process exited |
|
||||
|
||||
Replays the session's buffered output (capped at the last 5000 chunks) on connect, same as the
|
||||
exploit stream.
|
||||
Replays the session's buffered output (capped at the last ~512 KB) on connect, same as the exploit
|
||||
stream. Chunks are decoded with a streaming UTF-8 decoder, so a multi-byte character split across
|
||||
two reads still arrives intact — the browser feeds them straight into xterm.js.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+12
-4
@@ -2,8 +2,8 @@
|
||||
|
||||
A browser UI for the `neurosploit` CLI harness: a 5-step engagement wizard (Asset → Scope & Auth
|
||||
→ Leads → Model & Run → Review), a live structured findings view with a generative attack-path
|
||||
graph, run history, an Auth & Keys menu, and a real REPL — all driven by spawning the actual CLI
|
||||
binary, never a reimplementation of harness logic.
|
||||
graph, run history, an Auth & Keys menu, and a real terminal — all driven by spawning the actual
|
||||
CLI binary, never a reimplementation of harness logic.
|
||||
|
||||
- **Asset** — pick black/white/grey-box, host/infra, or AI/LLM, set the target or repo.
|
||||
- **Scope & Auth** — objective, focus, out-of-scope, and a link into the Auth & Keys menu.
|
||||
@@ -17,6 +17,13 @@ binary, never a reimplementation of harness logic.
|
||||
- **Generative Attack Path Chaining** — findings are grouped into kill-chain columns
|
||||
(recon → initial-access → execution → privesc → lateral → exfil → impact) with chained findings
|
||||
linked back to their parent, built live as findings stream in.
|
||||
- **Terminal dock (xterm.js)** — `❭_` in the sidebar, the topbar button, or `Ctrl+\`` opens a
|
||||
docked terminal running a real `neurosploit` REPL session. Its stdout is streamed **unstripped**,
|
||||
so the harness's own colour and box-drawn panels render as they do in a local shell. Line
|
||||
editing (echo, ←/→, history, `Tab` slash-command completion, `Ctrl+C`/`L`/`U`/`A`/`E`) is local
|
||||
because the child is spawned over a pipe, not a PTY, and therefore never echoes. The target
|
||||
picker in its header switches between a standalone session and the **running engagement**, so
|
||||
mid-run instructions go to the same process that is doing the testing.
|
||||
- **Real REPL underneath run/whitebox/greybox** — the wizard scripts an actual interactive
|
||||
`neurosploit` session instead of a one-shot CLI call, so it keeps reading stdin while the
|
||||
engagement streams. The Activity log tab grows a `❭` prompt box to send `/status`, `/stop`,
|
||||
@@ -39,8 +46,9 @@ web/
|
||||
├── server.js backend: static server + agents_md/runs reader + CLI process manager
|
||||
├── public/
|
||||
│ ├── index.html SPA shell
|
||||
│ ├── style.css lead-board / live-run / REPL drawer styling
|
||||
│ └── app.js client logic (fetch + EventSource, no framework)
|
||||
│ ├── style.css lead-board / live-run / terminal-dock styling
|
||||
│ ├── app.js client logic (fetch + EventSource + terminal, no framework)
|
||||
│ └── vendor/ xterm.js + fit addon (vendored; nothing is fetched at runtime)
|
||||
├── API.md
|
||||
└── package.json
|
||||
```
|
||||
|
||||
+625
-71
@@ -23,6 +23,7 @@ const state = {
|
||||
customLeads: [],
|
||||
filter: 'all',
|
||||
search: '',
|
||||
expandedCats: new Set(),
|
||||
providers: [],
|
||||
auth: { header: '', roles: [] },
|
||||
credsPath: '',
|
||||
@@ -31,7 +32,12 @@ const state = {
|
||||
currentJob: null,
|
||||
currentDetailId: null,
|
||||
detailPoll: null,
|
||||
replId: null, replEs: 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 },
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,7 +67,42 @@ 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); }
|
||||
|
||||
// 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
|
||||
@@ -71,6 +112,9 @@ 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';
|
||||
@@ -96,13 +140,26 @@ function goToStep(n) {
|
||||
updateWizardSummary();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (!$('#fieldName').value.trim()) { alert('Name the engagement first — it identifies this run in the sidebar and history.'); $('#fieldName').focus(); return false; }
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -118,7 +175,9 @@ function updateWizardSummary() {
|
||||
const target = $('#fieldTarget').value.trim() || '(not set)';
|
||||
$('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · <b>${esc(name)}</b> · ${esc(state.mode)} · ${esc(target)}`;
|
||||
}
|
||||
$('#fieldName').addEventListener('input', updateWizardSummary);
|
||||
$('#fieldName').addEventListener('input', () => { updateWizardSummary(); fieldError('#errName', ''); });
|
||||
$('#fieldTarget').addEventListener('input', () => fieldError('#errTarget', ''));
|
||||
$('#fieldRepo').addEventListener('input', () => fieldError('#errRepo', ''));
|
||||
|
||||
// mode tiles
|
||||
function selectMode(mode) {
|
||||
@@ -150,7 +209,12 @@ function renderBoard() {
|
||||
for (const group of state.categories) {
|
||||
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'cat-card';
|
||||
// 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 = `
|
||||
<div class="cat-head">
|
||||
<label class="switch">
|
||||
@@ -158,6 +222,7 @@ function renderBoard() {
|
||||
<span class="track"></span><span class="thumb"></span>
|
||||
</label>
|
||||
<span class="cat-name">${esc(group.category)}</span>
|
||||
<span class="cat-match" hidden></span>
|
||||
<span class="cat-count">${selCount} / ${group.agents.length}</span>
|
||||
<span class="caret">▾</span>
|
||||
</div>
|
||||
@@ -181,7 +246,9 @@ function renderBoard() {
|
||||
}
|
||||
card.querySelector('.cat-head').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.switch')) return;
|
||||
card.classList.toggle('collapsed');
|
||||
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",
|
||||
@@ -218,6 +285,7 @@ function updateChips() {
|
||||
|
||||
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;
|
||||
@@ -226,10 +294,22 @@ function applyFilters() {
|
||||
if (visible && q) visible = row.dataset.title.includes(q);
|
||||
row.classList.toggle('hidden-by-search', !visible);
|
||||
});
|
||||
let anyCardVisible = false;
|
||||
$$('.cat-card').forEach((card) => {
|
||||
const anyVisible = $$('.agent-row', card).some((r) => !r.classList.contains('hidden-by-search'));
|
||||
card.style.display = anyVisible ? '' : 'none';
|
||||
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', () => {
|
||||
@@ -243,39 +323,66 @@ $('#leadSearch').addEventListener('input', (e) => { state.search = e.target.valu
|
||||
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>
|
||||
<div class="custom-lead-chip"><span>${esc(text)}</span><span class="x" data-i="${i}" title="Remove">✕</span></div>
|
||||
`).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();
|
||||
}));
|
||||
}
|
||||
$('#btnCustomLead').addEventListener('click', async () => {
|
||||
const text = prompt('Describe the custom lead — Claude (Opus, subscription) generates a real specialist agent for it, ready to pin:');
|
||||
if (!text || !text.trim()) return;
|
||||
const btn = $('#btnCustomLead');
|
||||
|
||||
// `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.trim() }),
|
||||
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();
|
||||
alert(`Generated and pinned: ${agent.title}`);
|
||||
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.trim());
|
||||
state.customLeads.push(text);
|
||||
renderCustomLeads();
|
||||
alert(`Couldn't generate a skill (${e.message}) — added as a focus hint instead.`);
|
||||
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.
|
||||
@@ -393,7 +500,7 @@ async function startExploitation() {
|
||||
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) {
|
||||
alert('Failed to start: ' + e.message);
|
||||
toast(`Failed to start: ${e.message}`, 'error', 9000);
|
||||
} finally {
|
||||
$('#btnLaunch').disabled = false;
|
||||
$('#btnLaunch').textContent = 'Start Exploitation →';
|
||||
@@ -433,11 +540,12 @@ function attachLiveJob(id, target, name, pinnedAgents) {
|
||||
$('#livePhase').textContent = 'starting';
|
||||
$('#phaseDot').style.background = '';
|
||||
$('#phaseDot').classList.remove('static');
|
||||
$('#liveFindingsTable tbody').innerHTML = '';
|
||||
$('#liveAttackPath').innerHTML = '';
|
||||
$('#logList').innerHTML = '';
|
||||
$('#liveFindingsCount').textContent = '0';
|
||||
show($('#liveFindingsEmpty'), true);
|
||||
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';
|
||||
@@ -445,6 +553,7 @@ function attachLiveJob(id, target, name, pinnedAgents) {
|
||||
show($('#btnOpenReport'), false);
|
||||
show($('#sendPromptRow'), false);
|
||||
show($('#sendPromptHelp'), false);
|
||||
termSyncTargets();
|
||||
|
||||
const es = new EventSource(`/api/exploit/${id}/events`);
|
||||
state.currentJob.es = es;
|
||||
@@ -502,6 +611,9 @@ function appendLog(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
|
||||
@@ -532,12 +644,84 @@ function findingRow(f, idx) {
|
||||
<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-cwe">${esc(f.cwe)}</td>
|
||||
<td class="col-agent">${esc(f.agent)}</td>
|
||||
<td class="col-conf">${f.confidence ? f.confidence.toFixed(2) : '—'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) => `
|
||||
<button class="sev-pill sev-${s}${t.sev === s ? ' picked' : ''}" data-sev="${s}" title="${t.sev === s ? 'Show all severities' : `Show only ${s}`}">${s} <b>${counts[s]}</b></button>
|
||||
`).join('') || '<span class="field-help">No findings yet.</span>';
|
||||
$$(`${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) {
|
||||
@@ -552,11 +736,8 @@ bindFindingTableClicks('#liveFindingsTable tbody', () => state.currentJob?.findi
|
||||
bindFindingTableClicks('#detailFindingsTable tbody', () => state.detailFindings || [], () => state.currentDetailId, () => state.detailPocs || []);
|
||||
|
||||
function addFinding(f) {
|
||||
const idx = state.currentJob.findings.length;
|
||||
state.currentJob.findings.push(f);
|
||||
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f, idx));
|
||||
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
|
||||
show($('#liveFindingsEmpty'), false);
|
||||
renderFindings('live');
|
||||
renderAttackPath($('#liveAttackPath'), state.currentJob.findings, state.currentJob.target);
|
||||
}
|
||||
|
||||
@@ -588,6 +769,8 @@ function leaveLiveJob() {
|
||||
localStorage.removeItem(ACTIVE_JOB_KEY);
|
||||
clearInterval(state.currentJob?.pocPoll);
|
||||
state.currentJob?.es?.close();
|
||||
state.currentJob = null;
|
||||
termSyncTargets();
|
||||
}
|
||||
$('#btnBackToBoard').addEventListener('click', () => { leaveLiveJob(); show($('#liveView'), false); show($('#wizardView'), true); });
|
||||
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#wizardView'), true); });
|
||||
@@ -830,7 +1013,14 @@ function renderSidebar() {
|
||||
for (const r of g.items) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
||||
btn.innerHTML = `<span class="name">${esc(r.name || r.target)}</span><span class="sub">${r.name ? esc(r.target) + ' · ' : ''}${r.findings} finding(s)</span>`;
|
||||
// Every line here truncates: a long target URL used to run past the
|
||||
// sidebar's edge and collide with the main pane.
|
||||
const worst = SEV_ORDER.find((s) => Object.entries(r.severities || {}).some(([k, n]) => n && SEV_ORDER[sevRank(k)] === s));
|
||||
btn.innerHTML = `
|
||||
<span class="name">${worst ? `<span class="run-dot sev-dot-${worst}" title="worst severity: ${worst}"></span>` : ''}<span class="label">${esc(r.name || r.target)}</span></span>
|
||||
<span class="sub">${r.name ? esc(r.target) : esc(r.id)}</span>
|
||||
<span class="sub sub-facts"><span>${r.findings} finding${r.findings === 1 ? '' : 's'}</span><span>${esc(timeAgo(r.ts))}</span></span>`;
|
||||
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));
|
||||
items.appendChild(btn);
|
||||
const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId;
|
||||
@@ -860,17 +1050,33 @@ function openRun(run) {
|
||||
|
||||
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';
|
||||
$('#detailFindingsCount').textContent = detail.findings.length;
|
||||
state.detailFindings = detail.findings;
|
||||
state.detailPocs = detail.pocs || [];
|
||||
const tbody = $('#detailFindingsTable tbody');
|
||||
tbody.innerHTML = detail.findings.map((f, i) => findingRow(f, i)).join('');
|
||||
show($('#detailFindingsEmpty'), detail.findings.length === 0);
|
||||
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) => `<span>${esc(f)}</span>`).join('');
|
||||
renderAttackPath($('#detailAttackPath'), detail.findings, target);
|
||||
const reportLink = $('#detailOpenReport');
|
||||
if (detail.assets.includes('report.html')) {
|
||||
@@ -950,58 +1156,406 @@ async function refreshKeyStatus() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REPL drawer — real CLI harness session
|
||||
// Terminal dock — the real CLI harness, rendered by xterm.js
|
||||
//
|
||||
// The old drawer was a <div> 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openReplDrawer() { show($('#replDrawer'), true); if (!state.replId) startRepl(); }
|
||||
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',
|
||||
];
|
||||
|
||||
async function startRepl() {
|
||||
$('#replOutput').textContent = '';
|
||||
const { id } = await api('/api/repl', { method: 'POST' });
|
||||
state.replId = id;
|
||||
const es = new EventSource(`/api/repl/${id}/events`);
|
||||
state.replEs = es;
|
||||
es.addEventListener('data', (e) => {
|
||||
const { chunk } = JSON.parse(e.data);
|
||||
const out = $('#replOutput');
|
||||
out.appendChild(document.createTextNode(chunk));
|
||||
out.scrollTop = out.scrollHeight;
|
||||
});
|
||||
es.addEventListener('close', () => es.close());
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
$('#fabRepl').addEventListener('click', openReplDrawer);
|
||||
$('#btnOpenRepl').addEventListener('click', openReplDrawer);
|
||||
$('#btnReplClose').addEventListener('click', () => show($('#replDrawer'), false));
|
||||
$('#btnReplRestart').addEventListener('click', async () => {
|
||||
if (state.replId) await api(`/api/repl/${state.replId}/stop`, { method: 'POST' }).catch(() => {});
|
||||
state.replEs?.close();
|
||||
state.replId = null;
|
||||
startRepl();
|
||||
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 = `<option value="session">standalone REPL session</option>` +
|
||||
(job ? `<option value="job">live: ${esc(job.name || job.target)}</option>` : '');
|
||||
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();
|
||||
});
|
||||
$('#replInput').addEventListener('keydown', async (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const line = e.target.value;
|
||||
e.target.value = '';
|
||||
const out = $('#replOutput');
|
||||
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 }) });
|
||||
$('#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 (!$('#termDock').hidden) termClose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Below 768px the sidebar slides off-canvas. It previously had no way back:
|
||||
// the CSS hid it and nothing could set .open.
|
||||
$('#btnSidebarToggle').addEventListener('click', () => $('#sidebar').classList.toggle('open'));
|
||||
$('#sbGroups').addEventListener('click', () => {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) $('#sidebar').classList.remove('open');
|
||||
});
|
||||
|
||||
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()]);
|
||||
|
||||
+127
-51
@@ -5,10 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NeuroSploit v4.0.0 — Console</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>">
|
||||
<link rel="stylesheet" href="/vendor/xterm.css" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<button class="sidebar-toggle" id="btnSidebarToggle" title="Show / hide run history" aria-label="Show or hide run history">☰</button>
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
@@ -26,7 +29,7 @@
|
||||
<span class="sb-version" id="sbVersion">v4.0.0</span>
|
||||
<div class="sb-bottom-actions">
|
||||
<button class="icon-btn" id="btnOpenAuth" title="Auth & API keys">🔑</button>
|
||||
<button class="icon-btn" id="btnOpenRepl" title="Open REPL">❭_</button>
|
||||
<button class="icon-btn" id="btnOpenRepl" title="Open terminal (Ctrl+`)">❭_</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -42,6 +45,7 @@
|
||||
<div class="topbar-sub">Asset → Scope & Auth → Leads → Model & Run → Review</div>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button class="btn" id="btnOpenTerm2">❭_ Terminal</button>
|
||||
<button class="btn" id="btnOpenAuth2">🔑 Auth & Keys</button>
|
||||
</header>
|
||||
|
||||
@@ -58,8 +62,9 @@
|
||||
<!-- Step 1 — Asset -->
|
||||
<div class="wizard-panel" data-panel="0">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Engagement name</label>
|
||||
<input id="fieldName" type="text" placeholder="e.g. Keystone – Digital Banking" />
|
||||
<label class="field-label" for="fieldName">Engagement name</label>
|
||||
<input id="fieldName" type="text" placeholder="e.g. Keystone – Digital Banking" aria-describedby="errName" />
|
||||
<div class="field-error" id="errName" hidden></div>
|
||||
<div class="field-help">Identifies this engagement in the sidebar and run history — required.</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -75,13 +80,15 @@
|
||||
</div>
|
||||
|
||||
<div class="field-group" id="fieldTargetGroup">
|
||||
<label class="field-label" id="targetLabel">Target URL</label>
|
||||
<input id="fieldTarget" type="text" placeholder="https://target.example.com" />
|
||||
<label class="field-label" id="targetLabel" for="fieldTarget">Target URL</label>
|
||||
<input id="fieldTarget" type="text" placeholder="https://target.example.com" aria-describedby="errTarget" />
|
||||
<div class="field-error" id="errTarget" hidden></div>
|
||||
<div class="field-help" id="targetHelp">The application, host, or endpoint to test.</div>
|
||||
</div>
|
||||
<div class="field-group" id="fieldRepoGroup" hidden>
|
||||
<label class="field-label">Source repo</label>
|
||||
<input id="fieldRepo" type="text" placeholder="owner/repo, a GitHub URL, or a local path" />
|
||||
<label class="field-label" for="fieldRepo">Source repo</label>
|
||||
<input id="fieldRepo" type="text" placeholder="owner/repo, a GitHub URL, or a local path" aria-describedby="errRepo" />
|
||||
<div class="field-error" id="errRepo" hidden></div>
|
||||
<div class="field-help">Cloned automatically if it's a GitHub URL or owner/repo shorthand.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,47 +100,49 @@
|
||||
<div class="section-desc">Steers what the agents prioritise and what counts as impact.</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Objective</label>
|
||||
<label class="field-label" for="fieldObjective">Objective</label>
|
||||
<textarea id="fieldObjective" placeholder="e.g. Pre-launch review of the checkout flow — prove any path to unauthorized order access."></textarea>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Focus</label>
|
||||
<label class="field-label" for="fieldFocus">Focus</label>
|
||||
<textarea id="fieldFocus" placeholder="e.g. Prioritize the paths most likely to cause data leakage."></textarea>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Out of scope</label>
|
||||
<label class="field-label" for="fieldOutOfScope">Out of scope</label>
|
||||
<textarea id="fieldOutOfScope" placeholder="Hosts, paths, or techniques the agents must not touch."></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-title">Authentication</div>
|
||||
<div class="section-desc">Test as a logged-in user. Configured in the <button class="btn btn-sm" id="btnOpenAuth3" style="display:inline">🔑 Auth & Keys</button> menu.</div>
|
||||
<div class="section-desc">Test as a logged-in user. Configured in the <button class="btn btn-sm" id="btnOpenAuth3">🔑 Auth & Keys</button> menu.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 — Leads -->
|
||||
<div class="wizard-panel" data-panel="2" hidden style="max-width: none;">
|
||||
<div class="wizard-panel wizard-panel-wide" data-panel="2" hidden>
|
||||
<div>
|
||||
<div class="section-title">Set the action plan</div>
|
||||
<div class="section-desc">Toggle specific leads to test, or leave everything off to let recon-driven auto-selection choose.</div>
|
||||
</div>
|
||||
<div class="lead-toolbar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="leadSearch" type="text" placeholder="Search lead" />
|
||||
<span class="search-icon" aria-hidden="true">⌕</span>
|
||||
<input id="leadSearch" type="text" placeholder="Search leads by name or CWE" />
|
||||
</div>
|
||||
<div class="chips">
|
||||
<button class="chip chip-active" data-filter="all">All <span id="chipAll">0</span></button>
|
||||
<button class="chip" data-filter="selected">Selected <span id="chipSelected">0</span></button>
|
||||
<button class="chip" data-filter="excluded">Excluded <span id="chipExcluded">0</span></button>
|
||||
<button class="chip" data-filter="selected">Pinned <span id="chipSelected">0</span></button>
|
||||
<button class="chip" data-filter="excluded">Not pinned <span id="chipExcluded">0</span></button>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button class="btn btn-sm" id="btnExpandAll">Expand all</button>
|
||||
<button class="btn btn-sm" id="btnSelectAll">Select all</button>
|
||||
<button class="btn btn-sm" id="btnClearAll">Clear all</button>
|
||||
<button class="btn btn-sm" id="btnCustomLead">+ Custom lead</button>
|
||||
</div>
|
||||
<div class="field-help" style="margin: -8px 0 4px;">Tip: click a category's switch to select/deselect every lead in it at once.</div>
|
||||
<div class="custom-leads" id="customLeadsList"></div>
|
||||
<div class="field-help lead-tip">Categories start collapsed — click one to open it, or click its switch to pin every lead inside at once.</div>
|
||||
<div class="custom-leads" id="customLeadsList" hidden></div>
|
||||
<div class="categories" id="categories"><!-- populated --></div>
|
||||
<div class="empty-state" id="leadsEmpty" hidden>No lead matches that search.</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 — Model & Run -->
|
||||
@@ -144,11 +153,11 @@
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Provider</label>
|
||||
<label class="field-label" for="fieldProvider">Provider</label>
|
||||
<select id="fieldProvider"></select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Model</label>
|
||||
<label class="field-label" for="fieldModelSelect">Model</label>
|
||||
<select id="fieldModelSelect"></select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,15 +175,16 @@
|
||||
<div class="section-title">Run settings</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group"><label class="field-label">Votes</label><input class="narrow" id="fieldVotes" type="number" min="1" max="9" value="3" /></div>
|
||||
<div class="field-group"><label class="field-label">Chain depth</label><input class="narrow" id="fieldChain" type="number" min="0" max="5" value="2" /></div>
|
||||
<div class="field-group"><label class="field-label">Recon intensity</label>
|
||||
<div class="field-group"><label class="field-label" for="fieldVotes">Votes</label><input class="narrow" id="fieldVotes" type="number" min="1" max="9" value="3" /><div class="field-help">Models that must agree before a finding is kept.</div></div>
|
||||
<div class="field-group"><label class="field-label" for="fieldChain">Chain depth</label><input class="narrow" id="fieldChain" type="number" min="0" max="5" value="2" /><div class="field-help">How far findings get chained into attack paths.</div></div>
|
||||
<div class="field-group"><label class="field-label" for="fieldRecon">Recon intensity</label>
|
||||
<select class="narrow" id="fieldRecon">
|
||||
<option value="1">1 · quick</option>
|
||||
<option value="2">2 · standard</option>
|
||||
<option value="3" selected>3 · deep</option>
|
||||
<option value="4">4 · exhaustive</option>
|
||||
</select>
|
||||
<div class="field-help">Deeper recon finds more surface but costs more tokens.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +202,7 @@
|
||||
|
||||
<footer class="wizard-footer">
|
||||
<div class="summary-line" id="wizardSummary"></div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<div class="footer-actions">
|
||||
<button class="btn" id="btnStepBack">← Back</button>
|
||||
<button class="btn btn-primary" id="btnStepNext">Next →</button>
|
||||
<button class="btn btn-primary" id="btnLaunch" hidden>Start Exploitation →</button>
|
||||
@@ -203,13 +213,14 @@
|
||||
<!-- ============ LIVE RUN ============ -->
|
||||
<section class="runpage" id="liveView" hidden>
|
||||
<header class="run-head">
|
||||
<div>
|
||||
<div class="run-head-main">
|
||||
<div class="run-target" id="liveTarget">—</div>
|
||||
<div class="run-meta" id="liveTargetSub" style="font-family: var(--mono);"></div>
|
||||
<div class="run-meta" id="liveTargetSub"></div>
|
||||
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
||||
<div class="run-meta" id="livePinned" style="font-family: var(--mono);"></div>
|
||||
<div class="run-meta" id="livePinned"></div>
|
||||
</div>
|
||||
<div class="run-actions">
|
||||
<button class="btn" id="btnOpenTerm3">❭_ Terminal</button>
|
||||
<a class="btn" id="btnOpenReport" target="_blank" hidden>Open report</a>
|
||||
<button class="btn btn-danger" id="btnStopRun">Stop</button>
|
||||
<button class="btn" id="btnBackToBoard">← New engagement</button>
|
||||
@@ -225,13 +236,30 @@
|
||||
<button class="run-tab" data-tab="log">Activity log</button>
|
||||
</nav>
|
||||
<div class="run-body">
|
||||
<div class="run-tab-panel" data-tabpanel="findings"><table class="data-table" id="liveFindingsTable"><thead><tr><th>Severity</th><th>Title</th><th>Endpoint</th><th>CWE</th><th>Agent</th><th>Conf.</th></tr></thead><tbody></tbody></table><div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div></div>
|
||||
<div class="run-tab-panel" data-tabpanel="findings">
|
||||
<div class="findings-toolbar">
|
||||
<div class="sev-summary" id="liveSevSummary"></div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon" aria-hidden="true">⌕</span>
|
||||
<input id="liveFindingSearch" type="text" placeholder="Filter findings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="liveFindingsTable">
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div>
|
||||
</div>
|
||||
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="liveAttackPath"></div></div>
|
||||
<div class="run-tab-panel log-tab-panel" data-tabpanel="log" hidden>
|
||||
<div class="log-panel" id="logList"></div>
|
||||
<div class="send-prompt-row" id="sendPromptRow" hidden>
|
||||
<span class="repl-prompt">❭</span>
|
||||
<input id="sendPromptInput" type="text" autocomplete="off" spellcheck="false" placeholder="/status · /stop · /continue · or describe it in plain language" />
|
||||
<button class="btn btn-sm" id="btnSendPromptTerm" title="Open this session in the full terminal">Open in terminal</button>
|
||||
</div>
|
||||
<div class="field-help" id="sendPromptHelp" hidden>This session stays interactive while the engagement runs — type a command or plain instruction and press Enter.</div>
|
||||
</div>
|
||||
@@ -241,10 +269,11 @@
|
||||
<!-- ============ RUN DETAIL (past run) ============ -->
|
||||
<section class="runpage" id="detailView" hidden>
|
||||
<header class="run-head">
|
||||
<div>
|
||||
<div class="run-head-main">
|
||||
<div class="run-target" id="detailTarget">—</div>
|
||||
<div class="run-meta" id="detailTargetSub" style="font-family: var(--mono);"></div>
|
||||
<div class="run-meta" id="detailTargetSub"></div>
|
||||
<div class="run-meta"><span class="phase-dot static" id="detailDot"></span><span id="detailState">—</span></div>
|
||||
<div class="run-meta run-facts" id="detailFacts"></div>
|
||||
</div>
|
||||
<div class="run-actions">
|
||||
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
|
||||
@@ -256,11 +285,50 @@
|
||||
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
|
||||
</nav>
|
||||
<div class="run-body">
|
||||
<div class="run-tab-panel" data-tabpanel="findings"><table class="data-table" id="detailFindingsTable"><thead><tr><th>Severity</th><th>Title</th><th>Endpoint</th><th>CWE</th><th>Agent</th><th>Conf.</th></tr></thead><tbody></tbody></table><div class="empty-state" id="detailFindingsEmpty">No validated findings.</div></div>
|
||||
<div class="run-tab-panel" data-tabpanel="findings">
|
||||
<div class="findings-toolbar">
|
||||
<div class="sev-summary" id="detailSevSummary"></div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon" aria-hidden="true">⌕</span>
|
||||
<input id="detailFindingSearch" type="text" placeholder="Filter findings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="detailFindingsTable">
|
||||
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-state" id="detailFindingsEmpty">No validated findings.</div>
|
||||
</div>
|
||||
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="detailAttackPath"></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ TERMINAL DOCK (real CLI harness session) ============ -->
|
||||
<section class="term-dock" id="termDock" hidden>
|
||||
<div class="term-resize" id="termResize" title="Drag to resize"></div>
|
||||
<div class="term-head">
|
||||
<span class="term-dot" id="termDot" data-state="off"></span>
|
||||
<span class="term-title">NeuroSploit harness</span>
|
||||
<select class="term-target" id="termTarget" title="Where this terminal's input goes">
|
||||
<option value="session">standalone REPL session</option>
|
||||
</select>
|
||||
<span class="term-status" id="termStatus">disconnected</span>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button class="term-btn" id="btnTermClear" title="Clear screen (Ctrl+L)">clear</button>
|
||||
<button class="term-btn" id="btnTermRestart" title="Start a fresh harness session">restart</button>
|
||||
<button class="term-btn" id="btnTermExpand" title="Expand / collapse the dock">expand</button>
|
||||
<button class="icon-btn" id="btnTermClose" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
<div class="term-alert" id="termAlert" hidden></div>
|
||||
<div class="term-host" id="termHost"></div>
|
||||
<div class="term-hints" id="termHints">
|
||||
<span class="k">Enter</span> send · <span class="k">↑↓</span> history · <span class="k">Tab</span> complete · <span class="k">Ctrl+C</span> interrupt · <span class="k">Ctrl+L</span> clear
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +347,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="modal-panel" data-mpanel="target">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Auth header</label>
|
||||
<label class="field-label" for="authHeader">Auth header</label>
|
||||
<input id="authHeader" type="text" placeholder="Authorization: Bearer <token> or Cookie: session=..." />
|
||||
<div class="field-help">Used so agents test as a logged-in user. Kept only for this session, sent to the CLI as an ephemeral creds file.</div>
|
||||
</div>
|
||||
@@ -296,7 +364,7 @@
|
||||
</div>
|
||||
<div class="modal-panel" data-mpanel="creds" hidden>
|
||||
<div class="field-group">
|
||||
<label class="field-label">creds.yaml path (overrides target auth above)</label>
|
||||
<label class="field-label" for="credsPath">creds.yaml path (overrides target auth above)</label>
|
||||
<input id="credsPath" type="text" placeholder="creds.yaml" />
|
||||
<div class="field-help">An explicit file on disk — see neurosploit-rs/creds.example.yaml for the schema (jwt/header/cookie/login/roles/ssh/windows/cloud).</div>
|
||||
</div>
|
||||
@@ -305,9 +373,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ CUSTOM LEAD MODAL ============ -->
|
||||
<div class="modal-overlay" id="leadModal" hidden>
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-head">
|
||||
<div class="title">Custom lead</div>
|
||||
<button class="icon-btn" id="btnCloseLead">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="leadDesc">What should this lead test?</label>
|
||||
<textarea id="leadDesc" placeholder="e.g. Abuse the invoice PDF export to read internal files via the template engine."></textarea>
|
||||
<div class="field-help">Claude (Opus, subscription) generates a real specialist agent file for it and pins it. If generation is unavailable, the text is folded into <code>--focus</code> instead.</div>
|
||||
</div>
|
||||
<div class="field-error" id="errLead" hidden></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" id="btnLeadCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="btnLeadGenerate">Generate lead</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ FINDING DETAIL MODAL ============ -->
|
||||
<div class="modal-overlay" id="findingModal" hidden>
|
||||
<div class="modal" style="width: 760px;">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<span class="sev" id="fmSev">—</span>
|
||||
@@ -331,24 +421,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ REPL DRAWER ============ -->
|
||||
<div class="repl-drawer" id="replDrawer" hidden>
|
||||
<div class="repl-head">
|
||||
<span>NeuroSploit CLI harness — REPL</span>
|
||||
<div>
|
||||
<button class="icon-btn" id="btnReplRestart" title="Restart session">⟲</button>
|
||||
<button class="icon-btn" id="btnReplClose" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repl-output" id="replOutput"></div>
|
||||
<div class="repl-input-row">
|
||||
<span class="repl-prompt">❭</span>
|
||||
<input id="replInput" type="text" autocomplete="off" spellcheck="false" placeholder="/help · /run · /status · or describe it in plain language" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="fab" id="fabRepl" title="Open REPL">❭_</button>
|
||||
<div class="toasts" id="toasts"></div>
|
||||
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/xterm-addon-fit.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+135
-34
@@ -37,6 +37,12 @@
|
||||
--sev-info-bg: #e2e6f0; --sev-info-fg: #333e5c;
|
||||
|
||||
--shadow-float: 0 12px 32px rgba(20, 16, 8, 0.16), 0 2px 6px rgba(20, 16, 8, 0.08);
|
||||
|
||||
/* The terminal keeps a dark ground in both themes — it renders the CLI's own
|
||||
ANSI palette, which is authored for a dark background and washes out on a
|
||||
light one. Only the chrome around it follows the theme. */
|
||||
--term-bg: #131215;
|
||||
--term-fg: #ddd9d2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
@@ -62,6 +68,8 @@
|
||||
--sev-info-bg: #232840; --sev-info-fg: #9aa6d8;
|
||||
|
||||
--shadow-float: 0 16px 40px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
--term-bg: #0f0e10;
|
||||
--term-fg: #ddd9d2;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -116,15 +124,25 @@ a { color: var(--accent); text-decoration: none; }
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.sb-group-head .count { margin-left: auto; font-weight: 400; }
|
||||
.sb-group-head .caret { font-size: 9px; transition: transform .15s; }
|
||||
.sb-group-head .caret { font-size: 11px; line-height: 1; transition: transform .15s; }
|
||||
.sb-group.collapsed .caret { transform: rotate(-90deg); }
|
||||
.sb-group.collapsed .sb-items { display: none; }
|
||||
|
||||
.sb-run { display: block; width: 100%; text-align: left; background: transparent; border: none; border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-2); color: var(--text); margin-bottom: 1px; }
|
||||
.sb-run:hover { background: var(--surface-3); }
|
||||
.sb-run.active { background: var(--accent-soft); }
|
||||
.sb-run .name { font-size: 12.5px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sb-run .sub { display: block; font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-top: 2px; }
|
||||
.sb-run .name { font-size: 12.5px; font-weight: 500; display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.sb-run .name .label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
/* Every sidebar line clips inside the rail — a long target URL used to spill
|
||||
past the sidebar border into the main pane. */
|
||||
.sb-run .sub { display: block; font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sb-run .sub-facts { display: flex; justify-content: space-between; gap: var(--sp-2); }
|
||||
.run-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
|
||||
.sev-dot-critical { background: var(--sev-critical-fg); }
|
||||
.sev-dot-high { background: var(--sev-high-fg); }
|
||||
.sev-dot-medium { background: var(--sev-medium-fg); }
|
||||
.sev-dot-low { background: var(--sev-low-fg); }
|
||||
.sev-dot-info { background: var(--sev-info-fg); }
|
||||
|
||||
.sb-steps { padding: var(--sp-1) var(--sp-2) var(--sp-2) var(--sp-5); display: flex; flex-direction: column; gap: 2px; }
|
||||
.sb-step { font-size: 11px; color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-2); }
|
||||
@@ -185,12 +203,20 @@ a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
.wizard-body { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-5) var(--sp-8); }
|
||||
.wizard-panel { max-width: 780px; margin: 0 auto; display: flex; flex-direction: column; gap: var(--sp-6); }
|
||||
.wizard-panel-wide { max-width: none; gap: var(--sp-4); }
|
||||
.wizard-panel[hidden] { display: none; }
|
||||
.lead-tip { margin-top: calc(-1 * var(--sp-2)); }
|
||||
.cat-match { font-size: 10.5px; color: var(--accent); font-family: var(--mono); }
|
||||
.footer-actions { display: flex; gap: var(--sp-2); }
|
||||
|
||||
.field-group { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.field-group + .field-group { margin-top: var(--sp-5); }
|
||||
.field-label { font-size: 11px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--text-faint); }
|
||||
.field-help { font-size: 11.5px; color: var(--text-faint); }
|
||||
.field-error { font-size: 11.5px; color: var(--sev-critical-fg); font-weight: 500; }
|
||||
.field-error::before { content: "⚠ "; }
|
||||
.field-group:has(.field-error:not([hidden])) input,
|
||||
.field-group:has(.field-error:not([hidden])) textarea { border-color: var(--sev-critical-fg); }
|
||||
.field-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
|
||||
.field-row > * { flex: 1; min-width: 160px; }
|
||||
|
||||
@@ -230,8 +256,8 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
/* leads step reuses category cards */
|
||||
.lead-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
||||
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 320px; }
|
||||
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-faint); font-size: 12px; }
|
||||
#leadSearch { padding-left: 28px; }
|
||||
.search-icon { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--text-faint); font-size: 14px; line-height: 1; pointer-events: none; }
|
||||
.search-wrap input { padding-left: 28px; }
|
||||
.chips { display: flex; gap: var(--sp-2); }
|
||||
.chip { border: 1px solid var(--border-strong); background: var(--surface); color: var(--text-dim); border-radius: var(--radius-sm); padding: 6px 10px; font-size: 11.5px; display: flex; gap: 5px; }
|
||||
.chip span { color: var(--text-faint); font-family: var(--mono); }
|
||||
@@ -243,7 +269,7 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.cat-head { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-3); cursor: pointer; background: var(--surface); }
|
||||
.cat-head .cat-name { font-weight: 600; font-size: 12.5px; flex: 1; }
|
||||
.cat-head .cat-count { font-size: 11px; color: var(--text-faint); font-family: var(--mono); }
|
||||
.cat-head .caret { font-size: 9px; color: var(--text-faint); transition: transform .15s; }
|
||||
.cat-head .caret { font-size: 12px; line-height: 1; color: var(--text-faint); transition: transform .15s; }
|
||||
.cat-card.collapsed .caret { transform: rotate(-90deg); }
|
||||
.cat-card.collapsed .agent-rows { display: none; }
|
||||
.agent-rows { border-top: 1px solid var(--border); background: var(--surface-2); }
|
||||
@@ -286,8 +312,12 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.runpage { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.runpage[hidden] { display: none; }
|
||||
.run-head { display: flex; align-items: flex-start; justify-content: space-between; padding: var(--sp-5) var(--sp-5) var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||
.run-target { font-size: 15px; font-weight: 600; font-family: var(--mono); }
|
||||
.run-meta { display: flex; align-items: center; gap: var(--sp-3); margin-top: var(--sp-1); font-size: 12px; color: var(--text-dim); }
|
||||
.run-head-main { min-width: 0; }
|
||||
.run-target { font-size: 15px; font-weight: 600; font-family: var(--mono); overflow-wrap: anywhere; }
|
||||
.run-meta { display: flex; align-items: center; gap: var(--sp-3); margin-top: var(--sp-1); font-size: 12px; color: var(--text-dim); font-family: var(--mono); }
|
||||
#liveTargetSub:empty, #detailTargetSub:empty { display: none; }
|
||||
.run-facts { flex-wrap: wrap; font-size: 11px; color: var(--text-faint); }
|
||||
.run-facts span:not(:last-child)::after { content: " ·"; }
|
||||
.phase-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: pulse 1.4s infinite; }
|
||||
.phase-dot.static { animation: none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
@@ -317,15 +347,36 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.ap-node-g:hover rect:first-child { filter: brightness(0.97); }
|
||||
|
||||
/* findings table */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.data-table th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); font-weight: 600; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
/* Wide tables scroll inside their own box; without this the whole page
|
||||
scrolled sideways below ~900px and the last columns were unreachable. */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.findings-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-3); }
|
||||
.sev-summary { display: flex; gap: var(--sp-2); flex-wrap: wrap; align-items: center; }
|
||||
.sev-pill {
|
||||
border: 1px solid transparent; border-radius: var(--radius-sm); padding: 3px 9px;
|
||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .02em;
|
||||
}
|
||||
.sev-pill b { font-family: var(--mono); font-weight: 700; }
|
||||
.sev-pill.picked { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; min-width: 720px; }
|
||||
/* Sticky header: a 27-row findings table scrolls past its own column labels,
|
||||
and severity/confidence are unreadable without them. */
|
||||
.data-table th { position: sticky; top: 0; z-index: 1; background: var(--bg); text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); font-weight: 600; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-strong); white-space: nowrap; cursor: pointer; user-select: none; }
|
||||
.data-table th:hover { color: var(--text); }
|
||||
.data-table th.sorted { color: var(--accent); }
|
||||
.data-table th.sorted[data-dir="asc"]::after { content: " ↓"; }
|
||||
.data-table th.sorted[data-dir="desc"]::after { content: " ↑"; }
|
||||
.data-table td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.data-table tbody tr { cursor: pointer; }
|
||||
.data-table tbody tr:hover { background: var(--surface-2); }
|
||||
.poc-file { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
|
||||
.poc-file .fn { font-family: var(--mono); font-size: 12px; flex: 1; }
|
||||
.poc-pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); font-family: var(--mono); font-size: 11.5px; max-height: 220px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin-top: var(--sp-2); }
|
||||
.fm-prose { font-family: var(--sans); font-size: 12.5px; line-height: 1.6; color: var(--text-dim); white-space: pre-wrap; padding: var(--sp-1) 0; }
|
||||
/* Evidence prose carries raw cookies, tokens and URLs — single "words" wider
|
||||
than the modal. Without a break they run under the modal's edge and the
|
||||
tail of the value is simply unreadable. */
|
||||
.fm-prose { font-family: var(--sans); font-size: 12.5px; line-height: 1.6; color: var(--text-dim); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--sp-1) 0; }
|
||||
.review-item .v { overflow-wrap: anywhere; }
|
||||
.data-table .col-endpoint { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.data-table .col-conf { font-family: var(--mono); text-align: right; }
|
||||
.empty-state { padding: var(--sp-7) var(--sp-5); text-align: center; color: var(--text-faint); font-size: 12.5px; }
|
||||
@@ -351,6 +402,9 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(10,9,8,.45); display: flex; align-items: center; justify-content: center; z-index: 60; }
|
||||
.modal-overlay[hidden] { display: none; }
|
||||
.modal { width: 620px; max-width: calc(100vw - 40px); max-height: calc(100vh - 80px); background: var(--surface); border-radius: var(--radius-md); box-shadow: var(--shadow-float); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); }
|
||||
.modal-sm { width: 520px; }
|
||||
.modal-lg { width: 760px; }
|
||||
.modal-foot { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border); }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||
.modal-head .title { font-size: 14px; font-weight: 600; }
|
||||
.modal-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||
@@ -367,44 +421,91 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.provider-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); flex: none; }
|
||||
.provider-row .dot.set { background: var(--sev-low-fg); }
|
||||
|
||||
/* ============================================================ REPL drawer */
|
||||
/* ============================================================ Terminal dock
|
||||
|
||||
.repl-drawer {
|
||||
position: fixed; right: var(--sp-5); bottom: var(--sp-5); width: 560px; height: 400px;
|
||||
background: #0f0e10; color: #d8d5cf; border-radius: var(--radius-md); box-shadow: var(--shadow-float);
|
||||
display: flex; flex-direction: column; overflow: hidden; z-index: 50; border: 1px solid #2a282a;
|
||||
}
|
||||
.repl-drawer[hidden] { display: none; }
|
||||
.repl-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); background: #171618; font-size: 11.5px; color: #8f8b85; border-bottom: 1px solid #2a282a; }
|
||||
.repl-head .icon-btn { color: #8f8b85; }
|
||||
.repl-head .icon-btn:hover { background: #232123; color: #fff; }
|
||||
.repl-output { flex: 1; overflow-y: auto; padding: var(--sp-3); font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
|
||||
.repl-echo { color: var(--accent); }
|
||||
.repl-input-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border-top: 1px solid #2a282a; }
|
||||
.repl-prompt { color: #e08a3e; font-family: var(--mono); }
|
||||
#replInput { flex: 1; background: transparent; border: none; color: #ece9e4; font-family: var(--mono); font-size: 12.5px; outline: none; padding: 4px 0; }
|
||||
Docked, not floating: it is a flex child of .main, so opening it shortens
|
||||
the view above instead of covering the primary action the way the old
|
||||
floating drawer covered "Next →" and "Start Exploitation". */
|
||||
|
||||
.fab {
|
||||
position: fixed; right: var(--sp-5); bottom: var(--sp-5); width: 46px; height: 46px; border-radius: var(--radius-md);
|
||||
background: var(--accent); color: var(--accent-contrast); border: none; font-family: var(--mono); font-size: 14px;
|
||||
box-shadow: var(--shadow-float); z-index: 40;
|
||||
.term-dock {
|
||||
position: relative; flex: none; height: 320px; min-height: 140px;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
background: var(--term-bg); color: var(--term-fg); border-top: 1px solid var(--border-strong);
|
||||
}
|
||||
.fab:hover { background: var(--accent-hover); }
|
||||
.term-dock[hidden] { display: none; }
|
||||
.term-dock.expanded { flex: 1; height: auto; }
|
||||
|
||||
.term-resize { position: absolute; top: 0; left: 0; right: 0; height: 6px; cursor: ns-resize; z-index: 2; touch-action: none; }
|
||||
.term-resize:hover { background: var(--accent); opacity: .5; }
|
||||
body.resizing-ns { user-select: none; cursor: ns-resize; }
|
||||
|
||||
.term-head {
|
||||
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(255,255,255,.03); border-bottom: 1px solid rgba(255,255,255,.07);
|
||||
font-size: 11.5px; color: #a5a099;
|
||||
}
|
||||
.term-head .icon-btn { color: #a5a099; }
|
||||
.term-head .icon-btn:hover { background: rgba(255,255,255,.08); border-color: transparent; color: #fff; }
|
||||
.term-title { font-weight: 600; color: #ded9d2; }
|
||||
.term-target {
|
||||
width: auto; max-width: 260px; background: rgba(255,255,255,.05); color: #ded9d2;
|
||||
border: 1px solid rgba(255,255,255,.12); border-radius: var(--radius-sm); padding: 3px 6px; font-size: 11.5px;
|
||||
}
|
||||
.term-status { font-family: var(--mono); font-size: 11px; color: #8b8781; }
|
||||
.term-btn {
|
||||
background: transparent; border: 1px solid rgba(255,255,255,.12); color: #a5a099;
|
||||
border-radius: var(--radius-sm); padding: 3px 8px; font-size: 11px;
|
||||
}
|
||||
.term-btn:hover { background: rgba(255,255,255,.08); color: #fff; }
|
||||
.term-dot { width: 7px; height: 7px; border-radius: 50%; background: #6b6760; flex: none; }
|
||||
.term-dot[data-state="on"] { background: var(--sev-low-fg); }
|
||||
.term-dot[data-state="pending"] { background: var(--accent); animation: pulse 1.4s infinite; }
|
||||
.term-dot[data-state="error"] { background: var(--sev-critical-fg); }
|
||||
|
||||
.term-alert { padding: var(--sp-2) var(--sp-3); font-size: 11.5px; background: var(--sev-high-bg); color: var(--sev-high-fg); }
|
||||
.term-alert[data-kind="error"] { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
|
||||
.term-host { flex: 1; min-height: 0; padding: var(--sp-2) var(--sp-3) 0; }
|
||||
.term-host .xterm { height: 100%; }
|
||||
.term-hints { padding: 4px var(--sp-3) var(--sp-2); font-size: 10.5px; color: #77736d; display: flex; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.term-hints .k { font-family: var(--mono); color: #b3aea6; }
|
||||
|
||||
/* ============================================================ Toasts */
|
||||
|
||||
.toasts { position: fixed; right: var(--sp-5); bottom: var(--sp-5); z-index: 80; display: flex; flex-direction: column; gap: var(--sp-2); max-width: 380px; }
|
||||
.toast {
|
||||
border: 1px solid var(--border-strong); border-left-width: 3px; border-radius: var(--radius-sm);
|
||||
background: var(--surface); color: var(--text); padding: var(--sp-3) var(--sp-4); font-size: 12.5px;
|
||||
box-shadow: var(--shadow-float); cursor: pointer;
|
||||
}
|
||||
.toast-ok { border-left-color: var(--sev-low-fg); }
|
||||
.toast-warn { border-left-color: var(--sev-high-fg); }
|
||||
.toast-error { border-left-color: var(--sev-critical-fg); }
|
||||
.toast-info { border-left-color: var(--accent); }
|
||||
|
||||
/* ============================================================ Responsive */
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none; position: fixed; left: 10px; top: 10px; z-index: 60;
|
||||
width: 32px; height: 32px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--surface); color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar { width: 200px; }
|
||||
.sidebar { width: 208px; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 55; transition: left .2s; }
|
||||
.sidebar-toggle { display: block; }
|
||||
.sidebar { position: fixed; left: -260px; top: 0; bottom: 0; width: 240px; z-index: 55; transition: left .2s; box-shadow: var(--shadow-float); }
|
||||
.sidebar.open { left: 0; }
|
||||
.topbar, .run-head { padding-left: 52px; }
|
||||
.field-row { flex-direction: column; }
|
||||
.repl-drawer { width: calc(100vw - 24px); right: 12px; left: 12px; }
|
||||
.modal { width: calc(100vw - 24px); }
|
||||
.review-grid { grid-template-columns: 1fr; }
|
||||
.term-dock { height: 260px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.topbar { flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.stepper { padding: 0 var(--sp-3); }
|
||||
.wizard-footer { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
|
||||
.footer-actions { justify-content: flex-end; }
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
Vendored
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
+40
-8
@@ -22,6 +22,7 @@ const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { StringDecoder } = require('node:string_decoder');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
@@ -652,6 +653,11 @@ async function startJobViaRepl(body) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// REPL sessions — spawn `neurosploit` with no subcommand (Reader::Plain kicks
|
||||
// in over a piped stdin) and forward stdin/stdout verbatim: a real REPL.
|
||||
//
|
||||
// The stream is NOT ANSI-stripped: the browser renders it in xterm.js, which
|
||||
// is the same terminal emulator a local shell would drive, so colour, cursor
|
||||
// moves and the harness's own spinners arrive intact. Stripping here would
|
||||
// hand the emulator a degraded copy of what the CLI actually printed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const replSessions = new Map();
|
||||
@@ -663,10 +669,17 @@ class ReplSession extends EventEmitter {
|
||||
this.child = child;
|
||||
this.done = false;
|
||||
this.buffer = [];
|
||||
this.bytes = 0;
|
||||
}
|
||||
push(chunk) {
|
||||
this.buffer.push(chunk);
|
||||
if (this.buffer.length > 5000) this.buffer.shift();
|
||||
this.bytes += chunk.length;
|
||||
// Bound the replay buffer by size, not chunk count: one chunk can be a
|
||||
// single keystroke echo or a whole recon dump, so counting chunks caps
|
||||
// memory nowhere near where it's meant to.
|
||||
while (this.bytes > 512_000 && this.buffer.length > 1) {
|
||||
this.bytes -= this.buffer.shift().length;
|
||||
}
|
||||
this.emit('data', chunk);
|
||||
}
|
||||
}
|
||||
@@ -674,20 +687,28 @@ class ReplSession extends EventEmitter {
|
||||
function startRepl() {
|
||||
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
||||
const id = crypto.randomUUID();
|
||||
const child = spawn(BIN, [], { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||
const child = spawn(BIN, [], {
|
||||
cwd: ROOT,
|
||||
// Without TERM the harness assumes a dumb terminal and drops colour; the
|
||||
// browser side is a full xterm, so say so.
|
||||
env: { ...process.env, ...envOverrides(), TERM: 'xterm-256color' },
|
||||
});
|
||||
const session = new ReplSession(id, child);
|
||||
replSessions.set(id, session);
|
||||
const onData = (chunk) => session.push(stripAnsi(chunk.toString('utf8')));
|
||||
child.stdout.on('data', onData);
|
||||
child.stderr.on('data', onData);
|
||||
// One decoder per stream: a chunk boundary can land mid-UTF-8-sequence, and
|
||||
// decoding each chunk independently would emit replacement characters.
|
||||
const decOut = new StringDecoder('utf8');
|
||||
const decErr = new StringDecoder('utf8');
|
||||
child.stdout.on('data', (c) => session.push(decOut.write(c)));
|
||||
child.stderr.on('data', (c) => session.push(decErr.write(c)));
|
||||
child.on('close', (code) => {
|
||||
session.done = true;
|
||||
session.push(`\n[repl session ended, exit code ${code}]\n`);
|
||||
session.push(`\r\n\x1b[2m[repl session ended, exit code ${code}]\x1b[0m\r\n`);
|
||||
session.emit('close');
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
session.done = true;
|
||||
session.push(`\n[failed to start neurosploit: ${err.message}]\n`);
|
||||
session.push(`\r\n\x1b[31m[failed to start neurosploit: ${err.message}]\x1b[0m\r\n`);
|
||||
session.emit('close');
|
||||
});
|
||||
return session;
|
||||
@@ -904,7 +925,18 @@ const server = http.createServer(async (req, res) => {
|
||||
const session = replSessions.get(m[1]);
|
||||
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
||||
const body = await readBody(req);
|
||||
session.child.stdin.write(String(body.line ?? '') + '\n');
|
||||
// `data` is raw (whatever the terminal captured); `line` is the older
|
||||
// line-oriented form and still gets its newline appended here.
|
||||
const raw = body.data != null ? String(body.data) : String(body.line ?? '') + '\n';
|
||||
// Ctrl-C over a pipe is not a signal — nothing turns byte 0x03 into
|
||||
// SIGINT when there is no tty in between, so the interrupt has to be
|
||||
// delivered explicitly or it would silently do nothing.
|
||||
if (raw.includes('\x03')) {
|
||||
session.child.kill('SIGINT');
|
||||
return sendJson(res, 200, { ok: true, signalled: 'SIGINT' });
|
||||
}
|
||||
if (!session.child.stdin.writable) return sendJson(res, 409, { error: 'session has ended' });
|
||||
session.child.stdin.write(raw);
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
m = p.match(/^\/api\/repl\/([^/]+)\/stop$/);
|
||||
|
||||
Reference in New Issue
Block a user