mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-27 11:52:39 +02:00
feat(4.0.0): web console — lead board + live findings + real CLI REPL
New web/ app (zero npm deps, Node http built-ins only):
- server.js reads agents_md/ to build a categorized lead board (435 agents
auto-classified into Business Logic / Broken Access Control / Injection /
LLM Application / Auth & Session / SSRF / API / Cloud & Infra / etc.),
reads runs/ for history, and spawns the compiled neurosploit CLI binary
for every exploitation job — structured findings/phase/progress are parsed
from its stdout (finding_json:/phase lines), same signal the TUI uses.
- REPL drawer spawns `neurosploit` with no subcommand (real interactive
session, Reader::Plain over the piped stdin) and streams stdin/stdout —
every /command works exactly as in a terminal, nothing reimplemented.
- SSE endpoints for both job and REPL streams; run/finding/report assets
served under /api/runs/:id/asset/*.
- public/{index,app.js,style.css}: lead board with category toggles + custom
leads + Start Exploitation, live run view (progress/findings/log), run
detail view, REPL drawer — screenshot-inspired layout.
- web/API.md: full endpoint reference. web/README.md: quick start.
Bump version 3.6.9 -> 4.0.0 (Cargo.toml, CLI banners, README/TUTORIAL).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3ddb22ee25
commit
d1d1c71e24
+231
@@ -0,0 +1,231 @@
|
||||
# NeuroSploit Web Console — API reference
|
||||
|
||||
Backend: `web/server.js` (Node, zero external dependencies). It does three things:
|
||||
|
||||
1. Serves the SPA in `web/public/`.
|
||||
2. Reads `agents_md/` and `runs/` from the repo root to build the lead board and run history.
|
||||
3. Shells out to the compiled `neurosploit` CLI binary (`neurosploit-rs/target/release/neurosploit`)
|
||||
for every exploitation run and for the REPL — the web UI never reimplements harness logic,
|
||||
it only drives the real CLI and parses its stdout.
|
||||
|
||||
Base URL: `http://localhost:4173` (override with `PORT` or `NEUROSPLOIT_WEB_PORT`).
|
||||
|
||||
All responses are JSON unless noted. All endpoints are same-origin; there is no auth layer —
|
||||
run this only on a trusted machine/network, same trust model as the CLI itself.
|
||||
|
||||
---
|
||||
|
||||
## Meta
|
||||
|
||||
### `GET /api/meta`
|
||||
|
||||
Server/version info.
|
||||
|
||||
```json
|
||||
{ "version": "4.0.0", "binary": "/opt/neurosploit-rs/neurosploit-rs/target/release/neurosploit", "root": "/opt/neurosploit-rs" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agents / lead board
|
||||
|
||||
### `GET /api/agents`
|
||||
|
||||
Reads every `agents_md/{vulns,ai,infra,code,chains,recon,meta}/*.md`, extracts `name` (filename
|
||||
stem), `title` (first `# heading`), `cwe` (first `CWE-\d+` match), `kind` (source directory), and
|
||||
classifies each into a UI category (`category`) via a keyword taxonomy (Business Logic, Broken
|
||||
Access Control, Injection, Cross-Site Scripting, LLM Application, Auth & Session, SSRF & Network,
|
||||
API & GraphQL, Cloud & Infra, Client-Side, Cryptography, Rate Limiting & DoS, Cache & CDN,
|
||||
Recon & Fingerprint, Linux Host, Windows Host, Attack Chains, Code Review, Recon, Other).
|
||||
`meta/` (orchestration/doctrine agents) is loaded but excluded from `categories` — those aren't
|
||||
selectable "leads". Cached in-memory for 5s.
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 435,
|
||||
"agents": [ { "id": "sqli_error", "name": "sqli_error", "title": "SQL Injection (Error-Based) Specialist Agent", "cwe": "CWE-89", "kind": "vuln", "category": "Injection" } ],
|
||||
"categories": [ { "category": "Business Logic", "agents": [ /* Agent[] */ ] } ]
|
||||
}
|
||||
```
|
||||
|
||||
An agent's `id`/`name` is exactly what the CLI's `--only <name>` flag expects (see `neurosploit agents`).
|
||||
|
||||
---
|
||||
|
||||
## Runs (history)
|
||||
|
||||
### `GET /api/runs`
|
||||
|
||||
Lists `runs/ns-*` directories, newest first, with a summary read from each run's
|
||||
`meta.json` / `status.json` / `findings.json`.
|
||||
|
||||
```json
|
||||
[ { "id": "ns-1787504238-testphp_vulnweb_com", "ts": 1787504238, "target": "http://testphp.vulnweb.com/", "state": "running", "findings": 3, "severities": { "High": 1, "Medium": 2 }, "hasReport": false } ]
|
||||
```
|
||||
|
||||
`state` mirrors the CLI's `status.json`: `running` | `complete` | `stopped-raw` | `discarded` | `unknown`.
|
||||
|
||||
### `GET /api/runs/:id`
|
||||
|
||||
Full detail for one run: `{ id, meta, status, findings, assets }`. `findings` is the raw
|
||||
`findings.json` array (see [Finding shape](#finding-shape) below). `assets` lists which generated
|
||||
files exist (`report.html`, `report.pdf`, `report.md`, `recon.md`, `exploitation.md`).
|
||||
|
||||
### `GET /api/runs/:id/asset/:path`
|
||||
|
||||
Serves a file from that run's workdir (e.g. `report.html`, `report.pdf`, `evidence/foo.png`).
|
||||
Path-traversal-guarded (resolved path must stay under the run dir). Use this to embed/open the
|
||||
generated report from the browser.
|
||||
|
||||
---
|
||||
|
||||
## Exploitation jobs (live runs)
|
||||
|
||||
Starting a job spawns `neurosploit <mode> <target> [flags...] --verbose` as a child process and
|
||||
parses its stdout/stderr line-by-line into structured events — the same signal the interactive
|
||||
REPL's status line uses (phase, agent counts, findings, report path).
|
||||
|
||||
### `POST /api/exploit`
|
||||
|
||||
Body:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mode": "run", // run | whitebox | greybox | host | aitest | skills
|
||||
"target": "https://example.com", // required for run/host/aitest/greybox
|
||||
"repo": "owner/repo", // required for whitebox; source repo for greybox
|
||||
"models": ["anthropic:claude-opus-4-8"], // optional, repeatable in the CLI
|
||||
"votes": 3, // --vote-n
|
||||
"chainDepth": 2, // --chain-depth
|
||||
"recon": 3, // --recon (1-4)
|
||||
"maxAgents": 0, // --max-agents (0 = all)
|
||||
"subscription": false, // --subscription
|
||||
"offline": false, // --offline
|
||||
"mcp": false, // --mcp
|
||||
"creds": "creds.yaml", // --creds
|
||||
"focus": "injection and business logic", // --focus
|
||||
"objective": "pre-launch review of checkout", // --objective
|
||||
"outOfScope": "staging.example.com", // --out-of-scope
|
||||
"agents": ["sqli_error", "idor"] // --only <name>, repeated — the lead-board selection
|
||||
}
|
||||
```
|
||||
|
||||
Response: `{ "id": "<job-uuid>" }`. This `id` is the **web job id**, not the run id — the CLI's own
|
||||
`ns-<timestamp>-<target>` run id is discovered from its own log line and exposed as `runId` in the
|
||||
job snapshot once the engagement starts writing to `runs/`.
|
||||
|
||||
If `agents` is empty, no `--only` flag is passed and the harness falls back to its normal
|
||||
recon-driven agent selection (the intelligent default) — the lead board's "0 selected" state is a
|
||||
valid, meaningful choice, not an error.
|
||||
|
||||
### `GET /api/exploit`
|
||||
|
||||
List all jobs known to this server process (in-memory; lost on restart) as snapshots (see below).
|
||||
|
||||
### `GET /api/exploit/:id`
|
||||
|
||||
One job's current snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "d98f51ad-...", "target": "http://testphp.vulnweb.com/",
|
||||
"runId": "ns-1787504238-testphp_vulnweb_com",
|
||||
"phase": "exploiting", "findings": [ /* Finding[] */ ],
|
||||
"agents": 245, "agentsDone": 12, "done": false, "exitCode": null,
|
||||
"reportUrl": null, "startedAt": 1787504238594
|
||||
}
|
||||
```
|
||||
|
||||
`phase` tracks the same lifecycle the REPL's `/status` shows: `starting → recon → planning →
|
||||
exploiting → validating → chaining → complete`, or `paused (quota)` / `paused (auth)` if the
|
||||
harness parks the run (token/quota exhaustion or auth failure — findings are preserved either way).
|
||||
|
||||
### `POST /api/exploit/:id/stop`
|
||||
|
||||
Sends `SIGINT` to the child process — identical to pressing Ctrl-C in the terminal. The harness's
|
||||
own graceful-stop logic decides whether to keep partial findings.
|
||||
|
||||
### `GET /api/exploit/:id/events` (Server-Sent Events)
|
||||
|
||||
Live stream. On connect, replays every buffered event so a reconnecting client doesn't miss
|
||||
history, then streams new ones. Named SSE events:
|
||||
|
||||
| event | data | meaning |
|
||||
|------------|---------------------------------------|---------|
|
||||
| `log` | `{ "type": "log", "line": "..." }` | one stdout/stderr line (ANSI stripped) |
|
||||
| `finding` | `{ "type": "finding", "finding": {…} }` | a `finding_json:` line, parsed |
|
||||
| `snapshot` | job snapshot (see above) | phase/progress update |
|
||||
| `done` | job snapshot with `done: true` | process exited; stream closes |
|
||||
|
||||
Client example:
|
||||
|
||||
```js
|
||||
const es = new EventSource(`/api/exploit/${id}/events`);
|
||||
es.addEventListener('finding', (e) => console.log(JSON.parse(e.data).finding));
|
||||
es.addEventListener('done', () => es.close());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## REPL sessions
|
||||
|
||||
Spawns the CLI with **no subcommand** — the same interactive session `neurosploit` launches from
|
||||
a terminal — and pipes stdin/stdout. Because stdin isn't a TTY, the CLI's `Reader::Plain` path
|
||||
takes over: it prints each prompt to stdout then reads one line at a time from stdin, so it works
|
||||
perfectly over a plain pipe. This is a real harness process; every `/command` (`/run`, `/status`,
|
||||
`/stop`, `/model`, `/target`, natural-language input, etc.) behaves exactly as it would in a
|
||||
terminal.
|
||||
|
||||
### `POST /api/repl`
|
||||
|
||||
Starts a session. Response: `{ "id": "<session-uuid>" }`.
|
||||
|
||||
### `POST /api/repl/:id/input`
|
||||
|
||||
Body: `{ "line": "/status" }`. Writes `line + "\n"` to the child's stdin.
|
||||
|
||||
### `POST /api/repl/:id/stop`
|
||||
|
||||
Sends `SIGTERM` to the session's child process.
|
||||
|
||||
### `GET /api/repl/:id/events` (SSE)
|
||||
|
||||
| event | data | meaning |
|
||||
|---------|-------------------------|---------|
|
||||
| `data` | `{ "chunk": "..." }` | raw stdout/stderr chunk (ANSI stripped), 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.
|
||||
|
||||
---
|
||||
|
||||
## Finding shape
|
||||
|
||||
Findings are exactly the harness's `harness::types::Finding` struct (see
|
||||
`neurosploit-rs/crates/harness/src/types.rs`), serialized as JSON — the web UI does not transform
|
||||
or rename any field:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string, agent: string, title: string, severity: string, cwe: string, cvss: string,
|
||||
endpoint: string, payload: string, evidence: string, impact: string, remediation: string,
|
||||
confidence: number, validated: boolean, votes: string,
|
||||
owasp: string, mitre: string, stage: string, exploitability: string, business_impact: string,
|
||||
chains_from: string[], auth_context: string, account: string, secret: string,
|
||||
review_status: string, review_reason: string, screenshots: string[],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cd neurosploit-rs && cargo build --release # once, or after a harness change
|
||||
node web/server.js # http://localhost:4173
|
||||
```
|
||||
|
||||
`PORT` (or `NEUROSPLOIT_WEB_PORT`) overrides the port. The server auto-locates the compiled binary
|
||||
under `neurosploit-rs/target/{release,debug}/neurosploit` relative to the repo root; if neither
|
||||
exists, `/api/exploit` and `/api/repl` return a 500 with a build hint.
|
||||
@@ -0,0 +1,27 @@
|
||||
# NeuroSploit v4.0.0 — web console
|
||||
|
||||
A browser UI for the `neurosploit` CLI harness: a lead board (categorized agent picker + custom
|
||||
leads → `Start Exploitation`), a live structured findings view, run history, and a real REPL —
|
||||
all driven by spawning the actual CLI binary, never a reimplementation of harness logic.
|
||||
|
||||
```bash
|
||||
cd neurosploit-rs && cargo build --release # build the CLI once
|
||||
node web/server.js # → http://localhost:4173
|
||||
```
|
||||
|
||||
Zero npm dependencies (Node ≥18, built-ins only: `http`, `child_process`, `events`, `fs`).
|
||||
|
||||
API reference: [`API.md`](./API.md).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
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)
|
||||
├── API.md
|
||||
└── package.json
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "neurosploit-web",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"description": "NeuroSploit v4.0.0 web console — lead board + REPL, backed by the neurosploit CLI harness.",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
'use strict';
|
||||
/* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
categories: [], // from /api/agents
|
||||
selected: new Set(), // agent ids toggled on
|
||||
customLeads: [], // free-text custom leads (folded into --focus)
|
||||
filter: 'all', // all | selected | excluded
|
||||
search: '',
|
||||
runs: [], // from /api/runs
|
||||
currentJob: null, // {id, es} for the live view
|
||||
currentDetailId: null, // run id shown in detail view
|
||||
detailPoll: null,
|
||||
askFocus: '', askObjective: '', askOutOfScope: '',
|
||||
replId: null, replEs: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||
return res.headers.get('content-type')?.includes('json') ? res.json() : res.text();
|
||||
}
|
||||
function sevClass(sev) {
|
||||
const s = (sev || '').toLowerCase();
|
||||
if (s.includes('crit')) return 'sev-critical';
|
||||
if (s.includes('high')) return 'sev-high';
|
||||
if (s.includes('med')) return 'sev-medium';
|
||||
if (s.includes('low')) return 'sev-low';
|
||||
return 'sev-info';
|
||||
}
|
||||
function show(el, on) { el.hidden = !on; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agents / lead board
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadAgents() {
|
||||
const data = await api('/api/agents');
|
||||
state.categories = data.categories;
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const root = $('#categories');
|
||||
root.innerHTML = '';
|
||||
for (const group of state.categories) {
|
||||
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'cat-card';
|
||||
card.dataset.category = group.category;
|
||||
card.innerHTML = `
|
||||
<div class="cat-head">
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="cat-toggle" ${selCount === group.agents.length ? 'checked' : ''} />
|
||||
<span class="track"></span><span class="thumb"></span>
|
||||
</label>
|
||||
<span class="cat-name">${esc(group.category)}</span>
|
||||
<span class="cat-count">${selCount} / ${group.agents.length}</span>
|
||||
<span class="caret">▾</span>
|
||||
</div>
|
||||
<div class="agent-rows"></div>
|
||||
`;
|
||||
const rows = card.querySelector('.agent-rows');
|
||||
for (const a of group.agents) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'agent-row';
|
||||
row.dataset.id = a.id;
|
||||
row.dataset.title = (a.title + ' ' + a.name).toLowerCase();
|
||||
row.innerHTML = `
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="agent-toggle" data-id="${esc(a.id)}" ${state.selected.has(a.id) ? 'checked' : ''} />
|
||||
<span class="track"></span><span class="thumb"></span>
|
||||
</label>
|
||||
<span class="agent-title">${esc(a.title)}</span>
|
||||
${a.cwe ? `<span class="agent-cwe">${esc(a.cwe)}</span>` : ''}
|
||||
`;
|
||||
rows.appendChild(row);
|
||||
}
|
||||
card.querySelector('.cat-head').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.switch')) return;
|
||||
card.classList.toggle('collapsed');
|
||||
});
|
||||
card.querySelector('.cat-toggle').addEventListener('change', (e) => {
|
||||
const on = e.target.checked;
|
||||
for (const a of group.agents) {
|
||||
if (on) state.selected.add(a.id); else state.selected.delete(a.id);
|
||||
}
|
||||
renderBoard();
|
||||
updateChips();
|
||||
});
|
||||
rows.querySelectorAll('.agent-toggle').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const id = e.target.dataset.id;
|
||||
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
|
||||
renderBoard();
|
||||
updateChips();
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
root.appendChild(card);
|
||||
}
|
||||
updateChips();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function allAgents() {
|
||||
return state.categories.flatMap((g) => g.agents);
|
||||
}
|
||||
|
||||
function updateChips() {
|
||||
const total = allAgents().length;
|
||||
$('#chipAll').textContent = total;
|
||||
$('#chipSelected').textContent = state.selected.size;
|
||||
$('#chipExcluded').textContent = total - state.selected.size;
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const q = state.search.trim().toLowerCase();
|
||||
$$('.agent-row').forEach((row) => {
|
||||
const id = row.dataset.id;
|
||||
const isSel = state.selected.has(id);
|
||||
let visible = true;
|
||||
if (state.filter === 'selected') visible = isSel;
|
||||
if (state.filter === 'excluded') visible = !isSel;
|
||||
if (visible && q) visible = row.dataset.title.includes(q);
|
||||
row.classList.toggle('hidden-by-search', !visible);
|
||||
});
|
||||
$$('.cat-card').forEach((card) => {
|
||||
const anyVisible = [...card.querySelectorAll('.agent-row')].some((r) => !r.classList.contains('hidden-by-search'));
|
||||
card.style.display = anyVisible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// engagement bar / ask panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function currentMode() { return $('#fieldMode').value; }
|
||||
|
||||
$('#fieldMode').addEventListener('change', () => {
|
||||
show($('.eb-repo'), currentMode() === 'greybox');
|
||||
$('.eb-target label').textContent = currentMode() === 'whitebox' ? 'Repo / path' : 'Target';
|
||||
});
|
||||
|
||||
$('#btnAskSend').addEventListener('click', () => {
|
||||
const kind = $('#askKind').value;
|
||||
const text = $('#askInput').value.trim();
|
||||
if (!text) return;
|
||||
if (kind === 'focus') state.askFocus = text;
|
||||
if (kind === 'objective') state.askObjective = text;
|
||||
if (kind === 'scope-out') state.askOutOfScope = text;
|
||||
$('#askHint').textContent = `✓ ${kind} definido — aplicado no próximo "Start Exploitation"`;
|
||||
$('#askInput').value = '';
|
||||
});
|
||||
|
||||
$('#btnCustomLead').addEventListener('click', () => {
|
||||
const text = prompt('Descreva a lead customizada (linguagem livre — vira contexto de foco para os agentes):');
|
||||
if (text && text.trim()) {
|
||||
state.customLeads.push(text.trim());
|
||||
$('#askHint').textContent = `✓ custom lead adicionada (${state.customLeads.length} total)`;
|
||||
}
|
||||
});
|
||||
|
||||
$$('.chip').forEach((chip) => {
|
||||
chip.addEventListener('click', () => {
|
||||
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
|
||||
chip.classList.add('chip-active');
|
||||
state.filter = chip.dataset.filter;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
$('#leadSearch').addEventListener('input', (e) => { state.search = e.target.value; applyFilters(); });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// start exploitation → live run view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$('#btnStartExploitation').addEventListener('click', startExploitation);
|
||||
|
||||
async function startExploitation() {
|
||||
const mode = currentMode();
|
||||
const target = $('#fieldTarget').value.trim();
|
||||
const repo = $('#fieldRepo').value.trim();
|
||||
if (mode !== 'whitebox' && !target) { alert('Defina o target.'); return; }
|
||||
if (mode === 'whitebox' && !target && !repo) { alert('Defina o repo/path.'); return; }
|
||||
if (mode === 'greybox' && !repo) { alert('Grey-box precisa de repo + target.'); return; }
|
||||
|
||||
const modelField = $('#fieldModel').value.trim();
|
||||
const focusParts = [state.askFocus, ...state.customLeads].filter(Boolean);
|
||||
|
||||
const body = {
|
||||
mode,
|
||||
target: mode === 'whitebox' ? undefined : target,
|
||||
repo: mode === 'whitebox' ? (target || repo) : (repo || undefined),
|
||||
models: modelField ? [modelField] : [],
|
||||
votes: Number($('#fieldVotes').value) || 3,
|
||||
chainDepth: Number($('#fieldChain').value),
|
||||
recon: Number($('#fieldRecon').value),
|
||||
subscription: $('#fieldSubscription').checked,
|
||||
mcp: $('#fieldMcp').checked,
|
||||
agents: [...state.selected],
|
||||
focus: focusParts.join('; ') || undefined,
|
||||
objective: state.askObjective || undefined,
|
||||
outOfScope: state.askOutOfScope || undefined,
|
||||
};
|
||||
|
||||
$('#btnStartExploitation').disabled = true;
|
||||
try {
|
||||
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
attachLiveJob(id, body.target || body.repo);
|
||||
} catch (e) {
|
||||
alert('Falha ao iniciar: ' + e.message);
|
||||
} finally {
|
||||
$('#btnStartExploitation').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachLiveJob(id, target) {
|
||||
if (state.currentJob) state.currentJob.es.close();
|
||||
state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null };
|
||||
|
||||
show($('#boardView'), false);
|
||||
show($('#detailView'), false);
|
||||
show($('#liveView'), true);
|
||||
$('#liveTarget').textContent = target || '—';
|
||||
$('#livePhase').textContent = 'starting';
|
||||
$('#findingsList').innerHTML = '';
|
||||
$('#logList').innerHTML = '';
|
||||
$('#findingsCount').textContent = '0';
|
||||
$('#progressFill').style.width = '0%';
|
||||
$('#progressLabel').textContent = '0 / 0 agents';
|
||||
show($('#btnOpenReport'), false);
|
||||
|
||||
const es = new EventSource(`/api/exploit/${id}/events`);
|
||||
state.currentJob.es = es;
|
||||
es.addEventListener('log', (e) => appendLog(JSON.parse(e.data).line));
|
||||
es.addEventListener('finding', (e) => addFinding(JSON.parse(e.data).finding));
|
||||
es.addEventListener('snapshot', (e) => applySnapshot(JSON.parse(e.data)));
|
||||
es.addEventListener('done', (e) => { applySnapshot(JSON.parse(e.data)); refreshRuns(); });
|
||||
es.onerror = () => { /* browser auto-retries; fine for a long-running engagement */ };
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-line';
|
||||
div.textContent = line;
|
||||
const list = $('#logList');
|
||||
list.appendChild(div);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
function addFinding(f) {
|
||||
state.currentJob.findings.push(f);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'finding-card';
|
||||
card.innerHTML = `
|
||||
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
|
||||
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
|
||||
`;
|
||||
$('#findingsList').appendChild(card);
|
||||
$('#findingsCount').textContent = state.currentJob.findings.length;
|
||||
}
|
||||
|
||||
function applySnapshot(snap) {
|
||||
$('#livePhase').textContent = snap.phase;
|
||||
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
|
||||
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
|
||||
if (snap.reportUrl) {
|
||||
const link = $('#btnOpenReport');
|
||||
link.href = `/api/runs/${snap.runId}/asset/report.html`;
|
||||
show(link, !!snap.runId);
|
||||
}
|
||||
if (snap.done) $('#phaseDot').style.background = 'var(--green)';
|
||||
}
|
||||
|
||||
$('#btnStopRun').addEventListener('click', async () => {
|
||||
if (!state.currentJob) return;
|
||||
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
|
||||
});
|
||||
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#boardView'), true); });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sidebar — runs history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function refreshRuns() {
|
||||
try {
|
||||
state.runs = await api('/api/runs');
|
||||
} catch { state.runs = []; }
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
function stepClassFor(phase, step) {
|
||||
const order = ['recon', 'planning', 'exploiting', 'remediation'];
|
||||
const idx = { recon: 0, starting: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 }[phase] ?? 0;
|
||||
const stepIdx = order.indexOf(step);
|
||||
if (step === 'remediation') return 'pending'; // not automated yet — shown for roadmap parity only
|
||||
if (stepIdx < idx) return 'done';
|
||||
if (stepIdx === idx) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const root = $('#sbGroups');
|
||||
root.innerHTML = '';
|
||||
|
||||
const running = state.runs.filter((r) => r.state === 'running');
|
||||
const completed = state.runs.filter((r) => r.state !== 'running');
|
||||
|
||||
const groups = [
|
||||
{ label: 'Running', items: running, open: true },
|
||||
{ label: 'Completed', items: completed, open: true },
|
||||
];
|
||||
|
||||
for (const g of groups) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sb-group';
|
||||
wrap.innerHTML = `<div class="sb-group-head"><span class="caret">▾</span><span>${g.label}</span><span class="count">${g.items.length}</span></div><div class="sb-items"></div>`;
|
||||
wrap.querySelector('.sb-group-head').addEventListener('click', () => wrap.classList.toggle('collapsed'));
|
||||
const items = wrap.querySelector('.sb-items');
|
||||
for (const r of g.items) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
||||
btn.innerHTML = `${esc(r.target)}<span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
|
||||
btn.addEventListener('click', () => openRun(r));
|
||||
items.appendChild(btn);
|
||||
if (r.state === 'running' && state.currentJob) {
|
||||
const phase = state.currentJob.target === r.target ? $('#livePhase').textContent : null;
|
||||
const steps = document.createElement('div');
|
||||
steps.className = 'sb-steps';
|
||||
steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) =>
|
||||
`<div class="sb-step ${stepClassFor(phase || 'recon', s)}">${s[0].toUpperCase() + s.slice(1)}</div>`).join('');
|
||||
items.appendChild(steps);
|
||||
}
|
||||
}
|
||||
root.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function openRun(run) {
|
||||
state.currentDetailId = run.id;
|
||||
if (run.state === 'running' && state.currentJob) {
|
||||
show($('#boardView'), false); show($('#detailView'), false); show($('#liveView'), true);
|
||||
return;
|
||||
}
|
||||
show($('#boardView'), false); show($('#liveView'), false); show($('#detailView'), true);
|
||||
loadDetail(run.id);
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
async function loadDetail(id) {
|
||||
clearInterval(state.detailPoll);
|
||||
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
|
||||
$('#detailTarget').textContent = detail.status?.target || detail.meta?.target || id;
|
||||
$('#detailState').textContent = detail.status?.state || 'unknown';
|
||||
const list = $('#detailFindings');
|
||||
list.innerHTML = detail.findings.length
|
||||
? detail.findings.map((f) => `
|
||||
<div class="finding-card">
|
||||
<div class="f-top"><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span><span class="f-title">${esc(f.title)}</span></div>
|
||||
<div class="f-meta">${esc(f.cwe || '')} ${f.endpoint ? '· ' + esc(f.endpoint) : ''} ${f.agent ? '· ' + esc(f.agent) : ''}</div>
|
||||
</div>`).join('')
|
||||
: '<div class="f-meta">nenhum finding validado.</div>';
|
||||
const reportLink = $('#detailOpenReport');
|
||||
if (detail.assets.includes('report.html')) {
|
||||
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
|
||||
show(reportLink, true);
|
||||
} else show(reportLink, false);
|
||||
|
||||
if (detail.status?.state === 'running') {
|
||||
state.detailPoll = setInterval(() => loadDetail(id), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#boardView'), true); });
|
||||
$('#btnNewEngagement').addEventListener('click', () => { show($('#detailView'), false); show($('#liveView'), false); show($('#boardView'), true); });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REPL drawer — real CLI harness session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openReplDrawer() { show($('#replDrawer'), true); if (!state.replId) startRepl(); }
|
||||
|
||||
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.textContent += chunk;
|
||||
out.scrollTop = out.scrollHeight;
|
||||
});
|
||||
es.addEventListener('close', () => { es.close(); });
|
||||
}
|
||||
|
||||
$('#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();
|
||||
});
|
||||
$('#replInput').addEventListener('keydown', async (e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const line = e.target.value;
|
||||
e.target.value = '';
|
||||
const out = $('#replOutput');
|
||||
out.textContent += `❭ ${line}\n`;
|
||||
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 }) });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function boot() {
|
||||
const meta = await api('/api/meta').catch(() => ({}));
|
||||
$('#sbMeta').textContent = `v${meta.version || '4.0.0'}`;
|
||||
await loadAgents();
|
||||
await refreshRuns();
|
||||
setInterval(refreshRuns, 6000);
|
||||
}
|
||||
boot();
|
||||
@@ -0,0 +1,191 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
<aside class="sidebar">
|
||||
<div class="sb-top">
|
||||
<div class="brand">🧠</div>
|
||||
<div class="sb-icons">
|
||||
<button class="icon-btn" id="btnSearchRuns" title="Buscar engagement">⌕</button>
|
||||
<button class="icon-btn" id="btnCollapseSidebar" title="Recolher">⟨⟩</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="new-engagement" id="btnNewEngagement">+ Novo engagement</button>
|
||||
|
||||
<div class="sb-groups" id="sbGroups">
|
||||
<!-- populated by app.js -->
|
||||
</div>
|
||||
|
||||
<div class="sb-footer">
|
||||
<div class="sb-meta" id="sbMeta">v4.0.0</div>
|
||||
<button class="icon-btn" id="btnOpenRepl" title="Abrir REPL">⌘_</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ============ MAIN ============ -->
|
||||
<main class="main">
|
||||
|
||||
<!-- top bar -->
|
||||
<header class="topbar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="leadSearch" type="text" placeholder="Search lead" />
|
||||
</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>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
<button class="btn btn-ghost" id="btnCustomLead">+ Custom lead</button>
|
||||
<button class="btn btn-primary" id="btnStartExploitation">Start Exploitation →</button>
|
||||
</header>
|
||||
|
||||
<!-- engagement setup strip -->
|
||||
<section class="engagement-bar" id="engagementBar">
|
||||
<div class="eb-field eb-target">
|
||||
<label>Target</label>
|
||||
<input id="fieldTarget" type="text" placeholder="https://alvo.com ou owner/repo" />
|
||||
</div>
|
||||
<div class="eb-field">
|
||||
<label>Modo</label>
|
||||
<select id="fieldMode">
|
||||
<option value="run">Black-box (run)</option>
|
||||
<option value="whitebox">White-box</option>
|
||||
<option value="greybox">Grey-box</option>
|
||||
<option value="host">Host/Infra</option>
|
||||
<option value="aitest">AI/LLM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="eb-field eb-repo" hidden>
|
||||
<label>Repo (greybox)</label>
|
||||
<input id="fieldRepo" type="text" placeholder="owner/repo ou path local" />
|
||||
</div>
|
||||
<div class="eb-field eb-narrow">
|
||||
<label>Modelo</label>
|
||||
<input id="fieldModel" type="text" placeholder="anthropic:claude-opus-4-8" />
|
||||
</div>
|
||||
<div class="eb-field eb-narrow">
|
||||
<label>Votes</label>
|
||||
<input id="fieldVotes" type="number" min="1" max="9" value="3" />
|
||||
</div>
|
||||
<div class="eb-field eb-narrow">
|
||||
<label>Recon</label>
|
||||
<select 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>
|
||||
<div class="eb-field eb-narrow">
|
||||
<label>Chain</label>
|
||||
<input id="fieldChain" type="number" min="0" max="5" value="2" />
|
||||
</div>
|
||||
<label class="eb-check"><input type="checkbox" id="fieldSubscription" /> subscription</label>
|
||||
<label class="eb-check"><input type="checkbox" id="fieldMcp" /> MCP</label>
|
||||
</section>
|
||||
|
||||
<!-- ============ BOARD VIEW (lead picker) ============ -->
|
||||
<section class="board" id="boardView">
|
||||
<div class="board-scroll" id="boardScroll">
|
||||
<h2 class="board-title">Set and modify the action plan</h2>
|
||||
<div class="categories" id="categories"><!-- populated --></div>
|
||||
</div>
|
||||
|
||||
<aside class="ask-panel">
|
||||
<div class="ask-title">Ask anything</div>
|
||||
<textarea id="askInput" placeholder="e.g. Prioritize the paths most likely to cause data leak"></textarea>
|
||||
<div class="ask-row">
|
||||
<select id="askKind">
|
||||
<option value="focus">Focus</option>
|
||||
<option value="objective">Objective</option>
|
||||
<option value="scope-out">Out of scope</option>
|
||||
</select>
|
||||
<button class="btn btn-icon" id="btnAskSend" title="Aplicar">➤</button>
|
||||
</div>
|
||||
<div class="ask-hint" id="askHint"></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- ============ LIVE RUN VIEW ============ -->
|
||||
<section class="liverun" id="liveView" hidden>
|
||||
<div class="liverun-head">
|
||||
<div>
|
||||
<div class="liverun-target" id="liveTarget">—</div>
|
||||
<div class="liverun-phase"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
||||
</div>
|
||||
<div class="liverun-actions">
|
||||
<a class="btn btn-ghost" id="btnOpenReport" target="_blank" hidden>Abrir report</a>
|
||||
<button class="btn btn-danger" id="btnStopRun">Stop</button>
|
||||
<button class="btn btn-ghost" id="btnBackToBoard">← Board</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<div class="progress-label" id="progressLabel">0 / 0 agents</div>
|
||||
</div>
|
||||
|
||||
<div class="liverun-body">
|
||||
<div class="findings-col">
|
||||
<div class="col-head">Findings <span id="findingsCount">0</span></div>
|
||||
<div class="findings-list" id="findingsList"></div>
|
||||
</div>
|
||||
<div class="log-col">
|
||||
<div class="col-head">Activity feed</div>
|
||||
<div class="log-list" id="logList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ RUN DETAIL VIEW (past run) ============ -->
|
||||
<section class="rundetail" id="detailView" hidden>
|
||||
<div class="liverun-head">
|
||||
<div>
|
||||
<div class="liverun-target" id="detailTarget">—</div>
|
||||
<div class="liverun-phase" id="detailState">—</div>
|
||||
</div>
|
||||
<div class="liverun-actions">
|
||||
<a class="btn btn-ghost" id="detailOpenReport" target="_blank" hidden>Abrir report</a>
|
||||
<button class="btn btn-ghost" id="btnDetailBack">← Board</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="findings-list" id="detailFindings"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</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="Reiniciar sessão">⟲</button>
|
||||
<button class="icon-btn" id="btnReplClose" title="Fechar">✕</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 · ou descreva em linguagem natural" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="fab" id="fabRepl" title="Abrir REPL">❭_</button>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,242 @@
|
||||
:root {
|
||||
--bg: #f3efe9;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #faf8f5;
|
||||
--border: #e6e1d8;
|
||||
--text: #1c1a17;
|
||||
--text-dim: #7a746a;
|
||||
--text-faint: #a39d92;
|
||||
--accent: #1e2a5e;
|
||||
--accent-2: #2f3f8f;
|
||||
--accent-soft: #eef0fa;
|
||||
--gold: #e8b93f;
|
||||
--gold-soft: #fdf3d9;
|
||||
--green: #2e8b57;
|
||||
--red: #c0392b;
|
||||
--orange: #d97a1f;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
|
||||
--shadow: 0 1px 2px rgba(20, 16, 8, 0.04), 0 8px 24px rgba(20, 16, 8, 0.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #16151a;
|
||||
--panel: #1d1c22;
|
||||
--panel-2: #23222a;
|
||||
--border: #302f38;
|
||||
--text: #ecebf0;
|
||||
--text-dim: #9a97a6;
|
||||
--text-faint: #6d6a78;
|
||||
--accent: #6d7fe0;
|
||||
--accent-2: #8b9af0;
|
||||
--accent-soft: #262c4a;
|
||||
--gold: #e8b93f;
|
||||
--gold-soft: #3a3320;
|
||||
--green: #4fbf82;
|
||||
--red: #e0665a;
|
||||
--orange: #e69a4b;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font-family: var(--sans); }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
input, select, textarea { font-family: inherit; color: inherit; }
|
||||
|
||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ---------------- sidebar ---------------- */
|
||||
.sidebar {
|
||||
width: 260px; flex: none; background: var(--panel); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; padding: 14px 12px;
|
||||
}
|
||||
.sb-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.brand { width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 16px; }
|
||||
.sb-icons { display: flex; gap: 4px; }
|
||||
.icon-btn { background: transparent; border: 1px solid transparent; color: var(--text-dim); width: 28px; height: 28px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||
.icon-btn:hover { background: var(--panel-2); border-color: var(--border); }
|
||||
|
||||
.new-engagement {
|
||||
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
|
||||
border-radius: var(--radius-sm); padding: 9px 10px; font-size: 13px; text-align: left; margin-bottom: 12px;
|
||||
}
|
||||
.new-engagement:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.sb-groups { flex: 1; overflow-y: auto; }
|
||||
.sb-group { margin-bottom: 6px; }
|
||||
.sb-group-head {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 6px; font-size: 12px; color: var(--text-dim);
|
||||
text-transform: none; cursor: pointer; user-select: none;
|
||||
}
|
||||
.sb-group-head .count { margin-left: auto; opacity: .7; }
|
||||
.sb-group-head .caret { font-size: 10px; transition: transform .15s; }
|
||||
.sb-group.collapsed .caret { transform: rotate(-90deg); }
|
||||
.sb-group.collapsed .sb-items { display: none; }
|
||||
.sb-items { padding-left: 4px; }
|
||||
.sb-run {
|
||||
display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text);
|
||||
padding: 7px 8px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 2px;
|
||||
}
|
||||
.sb-run:hover { background: var(--panel-2); }
|
||||
.sb-run.active { background: var(--gold-soft); color: #7a5a00; font-weight: 600; }
|
||||
.sb-run .sub {
|
||||
display: block; font-size: 11px; color: var(--text-faint); font-weight: 400; margin-top: 1px;
|
||||
}
|
||||
.sb-steps { padding: 2px 8px 8px 20px; }
|
||||
.sb-step { font-size: 12px; padding: 3px 0; color: var(--text-faint); display: flex; align-items: center; gap: 6px; }
|
||||
.sb-step.done { color: var(--text-dim); }
|
||||
.sb-step.done::before { content: "✓"; color: var(--green); }
|
||||
.sb-step.active { color: var(--gold); font-weight: 600; }
|
||||
.sb-step.active::before { content: "◐"; }
|
||||
.sb-step.pending::before { content: "○"; opacity: .5; }
|
||||
|
||||
.sb-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||
.sb-meta { font-size: 11px; color: var(--text-faint); }
|
||||
|
||||
/* ---------------- main / topbar ---------------- */
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.topbar { display: flex; align-items: center; gap: 14px; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--bg); }
|
||||
.search-wrap { position: relative; }
|
||||
.search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-faint); font-size: 13px; }
|
||||
#leadSearch {
|
||||
width: 220px; padding: 8px 10px 8px 28px; border-radius: 999px; border: 1px solid var(--border);
|
||||
background: var(--panel); color: var(--text); font-size: 13px;
|
||||
}
|
||||
.chips { display: flex; gap: 8px; }
|
||||
.chip {
|
||||
border: 1px solid var(--border); background: var(--panel); color: var(--text-dim); border-radius: 999px;
|
||||
padding: 7px 12px; font-size: 12px; display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
.chip span { color: var(--text-faint); }
|
||||
.chip-active { background: var(--gold-soft); border-color: var(--gold); color: #7a5a00; }
|
||||
.chip-active span { color: #7a5a00; }
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
.btn { border-radius: var(--radius-sm); border: 1px solid var(--border); padding: 9px 14px; font-size: 13px; background: var(--panel); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||
.btn-primary:hover { background: var(--accent-2); }
|
||||
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
|
||||
.btn-icon { padding: 8px 10px; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.engagement-bar {
|
||||
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: end; padding: 12px 20px; background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.eb-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.eb-field label { font-size: 11px; color: var(--text-faint); }
|
||||
.eb-field input, .eb-field select {
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 7px 9px; font-size: 13px;
|
||||
background: var(--panel-2); color: var(--text); min-width: 120px;
|
||||
}
|
||||
.eb-target input { min-width: 260px; }
|
||||
.eb-narrow input, .eb-narrow select { width: 88px; min-width: 0; }
|
||||
.eb-check { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); margin-bottom: 2px; }
|
||||
|
||||
/* ---------------- board ---------------- */
|
||||
.board { flex: 1; display: flex; overflow: hidden; }
|
||||
.board-scroll { flex: 1; overflow-y: auto; padding: 18px 20px 40px; }
|
||||
.board-title { font-size: 15px; font-weight: 600; margin: 4px 0 16px; color: var(--text); }
|
||||
|
||||
.categories { display: flex; flex-direction: column; gap: 12px; max-width: 720px; }
|
||||
.cat-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.cat-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; user-select: none; }
|
||||
.cat-head .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); }
|
||||
.cat-head .cat-name { font-weight: 600; font-size: 13.5px; flex: 1; }
|
||||
.cat-head .cat-count { font-size: 12px; color: var(--text-faint); }
|
||||
.cat-head .caret { font-size: 10px; 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); }
|
||||
.agent-row { display: flex; align-items: center; gap: 10px; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.agent-row:last-child { border-bottom: none; }
|
||||
.agent-row.hidden-by-search { display: none; }
|
||||
.agent-row .agent-title { flex: 1; }
|
||||
.agent-row .agent-cwe { font-size: 11px; color: var(--text-faint); }
|
||||
.agent-row .agent-frac { font-size: 11px; color: var(--text-faint); width: 46px; text-align: right; }
|
||||
.agent-row .chevron { color: var(--text-faint); font-size: 12px; }
|
||||
|
||||
.switch { position: relative; width: 34px; height: 19px; flex: none; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch .track { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: background .15s; }
|
||||
.switch .thumb { position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: #fff; transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.switch input:checked + .track { background: var(--accent); }
|
||||
.switch input:checked + .track + .thumb { transform: translateX(15px); }
|
||||
|
||||
.ask-panel {
|
||||
width: 300px; flex: none; border-left: 1px solid var(--border); background: var(--panel); padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.ask-title { font-size: 12px; color: var(--text-faint); }
|
||||
#askInput {
|
||||
flex: 1; min-height: 90px; resize: vertical; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 10px; background: var(--panel-2); font-size: 13px;
|
||||
}
|
||||
.ask-row { display: flex; gap: 8px; }
|
||||
.ask-row select { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--panel-2); padding: 8px; font-size: 12px; }
|
||||
.ask-hint { font-size: 11px; color: var(--text-faint); min-height: 14px; }
|
||||
|
||||
/* ---------------- live run / detail ---------------- */
|
||||
.liverun, .rundetail { flex: 1; display: flex; flex-direction: column; overflow: hidden; padding: 18px 20px; }
|
||||
.liverun-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.liverun-target { font-weight: 600; font-size: 15px; }
|
||||
.liverun-phase { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; margin-top: 3px; }
|
||||
.phase-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gold); animation: pulse 1.4s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
.liverun-actions { display: flex; gap: 8px; }
|
||||
|
||||
.progress-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.progress-bar { flex: 1; height: 8px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; }
|
||||
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
|
||||
.progress-label { font-size: 12px; color: var(--text-faint); white-space: nowrap; }
|
||||
|
||||
.liverun-body { flex: 1; display: flex; gap: 16px; overflow: hidden; }
|
||||
.findings-col, .log-col { flex: 1; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.col-head { padding: 10px 14px; font-size: 12px; font-weight: 600; color: var(--text-dim); border-bottom: 1px solid var(--border); background: var(--panel-2); }
|
||||
.findings-list, .log-list { flex: 1; overflow-y: auto; padding: 8px 10px; }
|
||||
|
||||
.finding-card { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; margin-bottom: 8px; background: var(--panel-2); }
|
||||
.finding-card .f-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px; letter-spacing: .03em; }
|
||||
.sev-critical { background: #f6d9d5; color: #8c2a1c; }
|
||||
.sev-high { background: #fbe1cf; color: #8c4a10; }
|
||||
.sev-medium { background: #fdf0c8; color: #7a5a00; }
|
||||
.sev-low { background: #dcecdd; color: #235c34; }
|
||||
.sev-info { background: #e2e6f5; color: #2c3a7a; }
|
||||
.finding-card .f-title { font-size: 13px; font-weight: 600; }
|
||||
.finding-card .f-meta { font-size: 11px; color: var(--text-faint); margin-top: 3px; }
|
||||
|
||||
.log-line { font-family: var(--mono); font-size: 11.5px; color: var(--text-dim); padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* ---------------- REPL drawer ---------------- */
|
||||
.repl-drawer {
|
||||
position: fixed; right: 20px; bottom: 20px; width: 560px; height: 400px; background: #0e0e12; color: #dcdce0;
|
||||
border-radius: var(--radius); box-shadow: 0 20px 60px rgba(0,0,0,.45); display: flex; flex-direction: column;
|
||||
overflow: hidden; z-index: 50; border: 1px solid #2a2a33;
|
||||
}
|
||||
.repl-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #17171d; font-size: 12px; color: #9a97a6; border-bottom: 1px solid #2a2a33; }
|
||||
.repl-head .icon-btn { color: #9a97a6; }
|
||||
.repl-head .icon-btn:hover { background: #22222b; color: #fff; }
|
||||
.repl-output { flex: 1; overflow-y: auto; padding: 10px 12px; font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; }
|
||||
.repl-input-row { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid #2a2a33; }
|
||||
.repl-prompt { color: #6d7fe0; font-family: var(--mono); }
|
||||
#replInput { flex: 1; background: transparent; border: none; color: #ecebf0; font-family: var(--mono); font-size: 13px; outline: none; }
|
||||
|
||||
.fab {
|
||||
position: fixed; right: 20px; bottom: 20px; width: 52px; height: 52px; border-radius: 50%; background: var(--accent);
|
||||
color: #fff; border: none; font-family: var(--mono); font-size: 16px; box-shadow: 0 10px 24px rgba(30,42,94,.35);
|
||||
z-index: 40;
|
||||
}
|
||||
.fab:hover { background: var(--accent-2); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ask-panel { display: none; }
|
||||
.sidebar { width: 210px; }
|
||||
.repl-drawer { width: calc(100vw - 24px); left: 12px; right: 12px; }
|
||||
}
|
||||
+621
@@ -0,0 +1,621 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/**
|
||||
* NeuroSploit v4.0.0 — web console backend.
|
||||
*
|
||||
* Zero-dependency Node HTTP server that:
|
||||
* - serves the static SPA in ./public
|
||||
* - reads agents_md/ to build the "lead board" (agent/category picker)
|
||||
* - reads runs/ to build run history + structured findings
|
||||
* - spawns the compiled `neurosploit` CLI binary for exploitation runs and
|
||||
* streams its stdout (parsed into structured events) over SSE
|
||||
* - spawns the interactive `neurosploit` REPL (no subcommand) as a child
|
||||
* process and pipes stdin/stdout so the browser gets a REAL REPL
|
||||
* connected to the CLI harness — not a reimplementation.
|
||||
*/
|
||||
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const fsp = fs.promises;
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const { EventEmitter } = require('node:events');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WEB_DIR = __dirname;
|
||||
const ROOT = path.resolve(WEB_DIR, '..'); // repo root — holds agents_md/, runs/
|
||||
const AGENTS_DIR = path.join(ROOT, 'agents_md');
|
||||
const RUNS_DIR = path.join(ROOT, 'runs');
|
||||
const PUBLIC_DIR = path.join(WEB_DIR, 'public');
|
||||
|
||||
function findBinary() {
|
||||
const candidates = [
|
||||
path.join(ROOT, 'neurosploit-rs', 'target', 'release', 'neurosploit'),
|
||||
path.join(ROOT, 'neurosploit-rs', 'target', 'debug', 'neurosploit'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const BIN = findBinary();
|
||||
|
||||
const PORT = Number(process.env.NEUROSPLOIT_WEB_PORT || process.env.PORT || 4173);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent library — read agents_md/{vulns,ai,infra,code,chains,recon,meta}/*.md
|
||||
// and classify each into a lead category the UI can group + toggle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KIND_DIRS = {
|
||||
vulns: 'vuln',
|
||||
ai: 'ai',
|
||||
infra: 'infra',
|
||||
code: 'code',
|
||||
chains: 'chain',
|
||||
recon: 'recon',
|
||||
meta: 'meta',
|
||||
};
|
||||
|
||||
// Ordered classifier: first matching rule wins. Mirrors the vocabulary used
|
||||
// throughout agents_md/ so every agent lands in a sensible bucket for the
|
||||
// lead board (screenshot-style category groups).
|
||||
const CATEGORY_RULES = [
|
||||
[/^(llm_|mcp_|n8n_|redteam_|skill_|prompt_injection|ml_model_inversion|vector_db_injection)/, 'LLM Application'],
|
||||
[/^(xss_|dom_xss|blind_xss|mutation_xss|dom_clobbering|postmessage_vulnerability)/, 'Cross-Site Scripting'],
|
||||
[/^(jwt_|oauth_|oidc_|saml_|session_fixation|mfa_bypass|two_factor|twofa_|captcha_bypass|brute_force|default_credentials|weak_password|weak_jwt_secret|login_sqli_bypass|timing_side_channel_auth|timing_attack|refresh_token_abuse|auth_bypass|password_reset_poisoning)/, 'Auth & Session'],
|
||||
[/^(idor|bola|bfla|access_control_bypass|privilege_escalation|forced_browsing|authenticated_surface_exploit|exposed_admin_panel|spa_hidden_admin|mass_assignment|register_privilege_mass_assign|api_excessive_data|excessive_data_exposure|account_takeover_chain)/, 'Broken Access Control'],
|
||||
[/^(business_logic|spa_business_logic|coupon_logic_abuse|price_manipulation|workflow_step_skip|race_condition|idempotency_key_abuse|account_registration_and_forms)/, 'Business Logic'],
|
||||
[/^(sqli_|nosql_injection|ldap_injection|xpath_injection|xslt_injection|ssti|command_injection|log_injection|crlf_injection|header_injection|email_injection|smtp_injection|soap_injection|orm_injection|expression_language_injection|graphql_injection|css_injection|html_injection|csv_injection|formula_injection_excel|dangling_markup_injection|client_side_template_injection|server_side_prototype_pollution|prototype_pollution|xxe|path_traversal|^lfi$|^rfi$|zip_slip|log4shell_jndi|pickle_deserialization|insecure_deserialization|yaml_deserialization|type_juggling)/, 'Injection'],
|
||||
[/^(ssrf|gcp_metadata_ssrf|azure_imds_exposure|aws_imds_v2_bypass|host_header_injection|http_smuggling|http_desync|http2_request_smuggling|h2c_smuggling|reverse_proxy_path_confusion|websocket_)/, 'SSRF & Network'],
|
||||
[/^(graphql_|api_rate_limiting|rest_api_versioning|api_key_exposure|exposed_api_docs|spa_api_discovery|param_miner|parameter_pollution|grpc_reflection_exposure|api_bola)/, 'API & GraphQL'],
|
||||
[/^(aws_|azure_|gcp_|s3_bucket|gcs_bucket_misconfig|k8s_|docker_socket_exposure|container_escape|cloud_|terraform_state_exposure|helm_secret_exposure|ecr_public_exposure|serverless_|ci_cd_secret_leak|ad_)/, 'Cloud & Infra'],
|
||||
[/^(clickjacking|tabnabbing|cors_misconfig|insecure_cookie_flags|security_headers|subdomain_takeover|open_redirect|second_order_redirect|oauth_open_redirect_chain|csrf)/, 'Client-Side'],
|
||||
[/^(weak_encryption|weak_hashing|weak_random|padding_oracle|ecb_pattern_leak|ssl_issues|cleartext_transmission)/, 'Cryptography'],
|
||||
[/^(rate_limit|graphql_dos|regex_dos|range_header_dos|web_cache_poisoning_dos|llm_model_dos)/, 'Rate Limiting & DoS'],
|
||||
[/^(cache_poisoning|cdn_cache_key_poisoning|web_cache_deception|insecure_cdn|byte_range_cache|edge_side_includes)/, 'Cache & CDN'],
|
||||
[/^(cve_|eol_|version_disclosure|wordpress_audit|joomla_audit|drupal_audit|cms_|outdated_|dependency_confusion|typosquatting_package|vulnerable_dependency|git_exposed_repo|git_svn_exposure_app|source_code_disclosure|backup_file_exposure|env_file_exposure|debug_mode|aspnet_|appserver_exposure|misconfig_|iis_|information_disclosure|sensitive_data_exposure|directory_listing)/, 'Recon & Fingerprint'],
|
||||
[/^linux_/, 'Linux Host'],
|
||||
[/^windows_/, 'Windows Host'],
|
||||
];
|
||||
|
||||
function classify(name, kind) {
|
||||
if (kind === 'chain') return 'Attack Chains';
|
||||
if (kind === 'recon') return 'Recon';
|
||||
if (kind === 'code') return 'Code Review';
|
||||
if (kind === 'meta') return 'Meta & Reporting';
|
||||
if (kind === 'ai') return 'LLM Application';
|
||||
for (const [re, cat] of CATEGORY_RULES) {
|
||||
if (re.test(name)) return cat;
|
||||
}
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
function extractTitle(text, fallback) {
|
||||
const m = text.match(/^#\s+(.+?)\s*$/m);
|
||||
return m ? m[1].trim() : fallback;
|
||||
}
|
||||
function extractCwe(text) {
|
||||
const m = text.match(/CWE-\d+/);
|
||||
return m ? m[0] : '';
|
||||
}
|
||||
|
||||
let agentCache = null;
|
||||
let agentCacheAt = 0;
|
||||
|
||||
async function loadAgents() {
|
||||
const now = Date.now();
|
||||
if (agentCache && now - agentCacheAt < 5000) return agentCache;
|
||||
|
||||
const agents = [];
|
||||
for (const [dir, kind] of Object.entries(KIND_DIRS)) {
|
||||
const full = path.join(AGENTS_DIR, dir);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fsp.readdir(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of entries) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
const name = file.slice(0, -3);
|
||||
const text = await fsp.readFile(path.join(full, file), 'utf8').catch(() => '');
|
||||
agents.push({
|
||||
id: name,
|
||||
name,
|
||||
title: extractTitle(text, name),
|
||||
cwe: extractCwe(text),
|
||||
kind,
|
||||
category: classify(name, kind),
|
||||
});
|
||||
}
|
||||
}
|
||||
agents.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const byCategory = new Map();
|
||||
for (const a of agents) {
|
||||
if (!byCategory.has(a.category)) byCategory.set(a.category, []);
|
||||
byCategory.get(a.category).push(a);
|
||||
}
|
||||
// Selectable leads only (exclude meta/orchestration from the pentest board —
|
||||
// they're internal doctrine agents, not testable "leads").
|
||||
const LEAD_ORDER = [
|
||||
'Business Logic', 'Broken Access Control', 'Injection', 'Cross-Site Scripting',
|
||||
'LLM Application', 'Auth & Session', 'SSRF & Network', 'API & GraphQL',
|
||||
'Cloud & Infra', 'Client-Side', 'Cryptography', 'Rate Limiting & DoS',
|
||||
'Cache & CDN', 'Recon & Fingerprint', 'Linux Host', 'Windows Host',
|
||||
'Attack Chains', 'Code Review', 'Recon', 'Other',
|
||||
];
|
||||
const categories = LEAD_ORDER
|
||||
.filter((c) => byCategory.has(c) && c !== 'Meta & Reporting')
|
||||
.map((c) => ({ category: c, agents: byCategory.get(c) }));
|
||||
|
||||
agentCache = { total: agents.length, agents, categories };
|
||||
agentCacheAt = now;
|
||||
return agentCache;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runs — read runs/<id>/{meta,status,findings}.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function readJsonSafe(p, fallback) {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(p, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function listRuns() {
|
||||
let ids = [];
|
||||
try {
|
||||
ids = (await fsp.readdir(RUNS_DIR)).filter((d) => d.startsWith('ns-'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const runs = await Promise.all(ids.map(async (id) => {
|
||||
const dir = path.join(RUNS_DIR, id);
|
||||
const [meta, status, findings] = await Promise.all([
|
||||
readJsonSafe(path.join(dir, 'meta.json'), {}),
|
||||
readJsonSafe(path.join(dir, 'status.json'), {}),
|
||||
readJsonSafe(path.join(dir, 'findings.json'), []),
|
||||
]);
|
||||
const tsMatch = id.match(/^ns-(\d+)-/);
|
||||
const ts = tsMatch ? Number(tsMatch[1]) : 0;
|
||||
const sevCount = {};
|
||||
for (const f of findings) sevCount[f.severity] = (sevCount[f.severity] || 0) + 1;
|
||||
return {
|
||||
id,
|
||||
ts,
|
||||
target: status.target || meta.target || id.replace(/^ns-\d+-/, ''),
|
||||
state: status.state || 'unknown',
|
||||
findings: findings.length,
|
||||
severities: sevCount,
|
||||
hasReport: fs.existsSync(path.join(dir, 'report.html')) || fs.existsSync(path.join(dir, 'report.pdf')),
|
||||
};
|
||||
}));
|
||||
runs.sort((a, b) => b.ts - a.ts);
|
||||
return runs;
|
||||
}
|
||||
|
||||
async function runDetail(id) {
|
||||
const dir = safeRunDir(id);
|
||||
if (!dir) return null;
|
||||
const [meta, status, findings] = await Promise.all([
|
||||
readJsonSafe(path.join(dir, 'meta.json'), {}),
|
||||
readJsonSafe(path.join(dir, 'status.json'), {}),
|
||||
readJsonSafe(path.join(dir, 'findings.json'), []),
|
||||
]);
|
||||
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
||||
.filter((f) => fs.existsSync(path.join(dir, f)));
|
||||
return { id, meta, status, findings, assets };
|
||||
}
|
||||
|
||||
function safeRunDir(id) {
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(id)) return null;
|
||||
const dir = path.join(RUNS_DIR, id);
|
||||
if (!dir.startsWith(RUNS_DIR)) return null;
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exploitation jobs — spawn `neurosploit <mode> <target> --only ... -v`
|
||||
// and parse its stdout into structured live state (mirrors app/src/repl.rs
|
||||
// RunLive::ingest so the web UI gets the same phases/findings the TUI does).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jobs = new Map(); // id -> Job
|
||||
|
||||
class Job extends EventEmitter {
|
||||
constructor(id, cmd, args, target) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.cmd = cmd;
|
||||
this.args = args;
|
||||
this.target = target || '';
|
||||
this.runId = null; // ns-<ts>-<target> workdir basename, once known
|
||||
this.phase = 'starting';
|
||||
this.findings = [];
|
||||
this.feed = [];
|
||||
this.agents = 0;
|
||||
this.agentsDone = 0;
|
||||
this.done = false;
|
||||
this.exitCode = null;
|
||||
this.reportUrl = null;
|
||||
this.startedAt = Date.now();
|
||||
this.child = null;
|
||||
}
|
||||
push(evt) {
|
||||
this.feed.push(evt);
|
||||
if (this.feed.length > 2000) this.feed.shift();
|
||||
this.emit('event', evt);
|
||||
}
|
||||
snapshot() {
|
||||
return {
|
||||
id: this.id,
|
||||
target: this.target,
|
||||
runId: this.runId,
|
||||
phase: this.phase,
|
||||
findings: this.findings,
|
||||
agents: this.agents,
|
||||
agentsDone: this.agentsDone,
|
||||
done: this.done,
|
||||
exitCode: this.exitCode,
|
||||
reportUrl: this.reportUrl,
|
||||
startedAt: this.startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
|
||||
|
||||
function ingestLine(job, rawLine) {
|
||||
const line = stripAnsi(rawLine);
|
||||
const low = line.toLowerCase();
|
||||
job.push({ type: 'log', line });
|
||||
|
||||
if (low.includes('token/quota exhausted') || low.includes('run is paused')) job.phase = 'paused (quota)';
|
||||
else if (low.includes('authentication failed') || low.includes('circuit breaker')) job.phase = 'paused (auth)';
|
||||
else if (low.startsWith('recon') || low.startsWith('ai-recon') || low.includes('recon round') || low.startsWith('probe:')) job.phase = 'recon';
|
||||
else if (low.includes('selected') && low.includes('agent')) {
|
||||
job.phase = 'planning';
|
||||
const n = line.split(/\s+/).map(Number).find((x) => Number.isFinite(x));
|
||||
if (n) job.agents = n;
|
||||
} else if (low.startsWith('exploit') || low.startsWith('test ') || low.includes('launching agent')) job.phase = 'exploiting';
|
||||
else if (low.startsWith('vote') || low.includes('validating')) job.phase = 'validating';
|
||||
else if (low.startsWith('chain')) job.phase = 'chaining';
|
||||
else if (low.includes('phase complete') || low.includes('validated finding(s)')) job.phase = 'complete';
|
||||
|
||||
if (/candidate\(s\)/.test(low) && /^(exploit |test |analyze |review )/.test(low)) job.agentsDone += 1;
|
||||
|
||||
const fj = line.match(/^finding_json:\s*(.+)$/);
|
||||
if (fj) {
|
||||
try {
|
||||
const finding = JSON.parse(fj[1]);
|
||||
job.findings.push(finding);
|
||||
job.push({ type: 'finding', finding });
|
||||
} catch { /* ignore malformed line */ }
|
||||
}
|
||||
|
||||
const rep = line.match(/report:\s*(file:\/\/\S+)/);
|
||||
if (rep) job.reportUrl = rep[1];
|
||||
|
||||
const rid = line.match(/run id\s*:\s*(\S+)/);
|
||||
if (rid) job.runId = rid[1];
|
||||
}
|
||||
|
||||
function buildArgs(body) {
|
||||
const mode = body.mode || 'run';
|
||||
const args = [mode];
|
||||
if (mode === 'run' || mode === 'host' || mode === 'aitest') {
|
||||
args.push(body.target);
|
||||
} else if (mode === 'whitebox' || mode === 'skills') {
|
||||
args.push(body.repo || body.target);
|
||||
} else if (mode === 'greybox') {
|
||||
args.push(body.repo);
|
||||
args.push('--url', body.target);
|
||||
}
|
||||
for (const m of body.models || []) args.push('--model', m);
|
||||
if (body.votes) args.push('--vote-n', String(body.votes));
|
||||
if (body.chainDepth !== undefined) args.push('--chain-depth', String(body.chainDepth));
|
||||
if (body.recon) args.push('--recon', String(body.recon));
|
||||
if (body.maxAgents) args.push('--max-agents', String(body.maxAgents));
|
||||
if (body.offline) args.push('--offline');
|
||||
if (body.subscription) args.push('--subscription');
|
||||
if (body.mcp) args.push('--mcp');
|
||||
if (body.creds) args.push('--creds', body.creds);
|
||||
if (body.focus) args.push('--focus', body.focus);
|
||||
if (body.objective) args.push('--objective', body.objective);
|
||||
if (body.outOfScope) args.push('--out-of-scope', body.outOfScope);
|
||||
for (const a of body.agents || []) args.push('--only', a);
|
||||
args.push('--verbose');
|
||||
return args;
|
||||
}
|
||||
|
||||
function startJob(body) {
|
||||
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
||||
const id = crypto.randomUUID();
|
||||
const args = buildArgs(body);
|
||||
const job = new Job(id, BIN, args, body.repo || body.target || '');
|
||||
jobs.set(id, job);
|
||||
|
||||
const child = spawn(BIN, args, { cwd: ROOT, env: process.env });
|
||||
job.child = child;
|
||||
let buf = '';
|
||||
const onData = (chunk) => {
|
||||
buf += chunk.toString('utf8');
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
if (line.length) ingestLine(job, line);
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', onData);
|
||||
child.stderr.on('data', onData);
|
||||
child.on('close', (code) => {
|
||||
if (buf.trim()) ingestLine(job, buf);
|
||||
job.done = true;
|
||||
job.exitCode = code;
|
||||
job.phase = job.phase === 'paused (quota)' || job.phase === 'paused (auth)' ? job.phase : 'complete';
|
||||
job.push({ type: 'done', exitCode: code });
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
job.done = true;
|
||||
job.push({ type: 'log', line: `[web] failed to start neurosploit: ${err.message}` });
|
||||
job.push({ type: 'done', exitCode: -1 });
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// REPL sessions — spawn `neurosploit` with no subcommand (Reader::Plain kicks
|
||||
// in over a piped stdin) and forward stdin/stdout verbatim: a real REPL.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const replSessions = new Map();
|
||||
|
||||
class ReplSession extends EventEmitter {
|
||||
constructor(id, child) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.child = child;
|
||||
this.done = false;
|
||||
this.buffer = [];
|
||||
}
|
||||
push(chunk) {
|
||||
this.buffer.push(chunk);
|
||||
if (this.buffer.length > 5000) this.buffer.shift();
|
||||
this.emit('data', chunk);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
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);
|
||||
child.on('close', (code) => {
|
||||
session.done = true;
|
||||
session.push(`\n[repl session ended, exit code ${code}]\n`);
|
||||
session.emit('close');
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
session.done = true;
|
||||
session.push(`\n[failed to start neurosploit: ${err.message}]\n`);
|
||||
session.emit('close');
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny HTTP plumbing (no framework)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sendJson(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => { data += c; if (data.length > 5_000_000) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
if (!data) return resolve({});
|
||||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sseInit(res) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write(':ok\n\n');
|
||||
}
|
||||
function sseSend(res, event, data) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
async function serveStatic(req, res, urlPath) {
|
||||
let rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
const full = path.join(PUBLIC_DIR, rel);
|
||||
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end(); return; }
|
||||
try {
|
||||
const data = await fsp.readFile(full);
|
||||
const ext = path.extname(full);
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('not found');
|
||||
}
|
||||
}
|
||||
|
||||
async function serveRunAsset(req, res, id, rest) {
|
||||
const dir = safeRunDir(id);
|
||||
if (!dir) { res.writeHead(400); res.end(); return; }
|
||||
const full = path.join(dir, rest);
|
||||
if (!full.startsWith(dir)) { res.writeHead(403); res.end(); return; }
|
||||
try {
|
||||
const data = await fsp.readFile(full);
|
||||
const ext = path.extname(full);
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('not found');
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const u = new URL(req.url, 'http://localhost');
|
||||
const p = u.pathname;
|
||||
|
||||
try {
|
||||
// ---- static ----
|
||||
if (req.method === 'GET' && !p.startsWith('/api/')) {
|
||||
return serveStatic(req, res, p);
|
||||
}
|
||||
|
||||
// ---- agents / lead board ----
|
||||
if (req.method === 'GET' && p === '/api/agents') {
|
||||
return sendJson(res, 200, await loadAgents());
|
||||
}
|
||||
|
||||
// ---- runs ----
|
||||
if (req.method === 'GET' && p === '/api/runs') {
|
||||
return sendJson(res, 200, await listRuns());
|
||||
}
|
||||
let m = p.match(/^\/api\/runs\/([^/]+)$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
const detail = await runDetail(decodeURIComponent(m[1]));
|
||||
if (!detail) return sendJson(res, 404, { error: 'run not found' });
|
||||
return sendJson(res, 200, detail);
|
||||
}
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/asset\/(.+)$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
return serveRunAsset(req, res, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
||||
}
|
||||
|
||||
// ---- exploitation jobs ----
|
||||
if (req.method === 'GET' && p === '/api/exploit') {
|
||||
return sendJson(res, 200, [...jobs.values()].map((j) => j.snapshot()));
|
||||
}
|
||||
if (req.method === 'POST' && p === '/api/exploit') {
|
||||
const body = await readBody(req);
|
||||
const job = startJob(body);
|
||||
return sendJson(res, 200, { id: job.id });
|
||||
}
|
||||
m = p.match(/^\/api\/exploit\/([^/]+)$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
const job = jobs.get(m[1]);
|
||||
if (!job) return sendJson(res, 404, { error: 'job not found' });
|
||||
return sendJson(res, 200, job.snapshot());
|
||||
}
|
||||
m = p.match(/^\/api\/exploit\/([^/]+)\/stop$/);
|
||||
if (req.method === 'POST' && m) {
|
||||
const job = jobs.get(m[1]);
|
||||
if (!job) return sendJson(res, 404, { error: 'job not found' });
|
||||
job.child?.kill('SIGINT');
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
m = p.match(/^\/api\/exploit\/([^/]+)\/events$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
const job = jobs.get(m[1]);
|
||||
if (!job) { res.writeHead(404); return res.end(); }
|
||||
sseInit(res);
|
||||
// replay what already happened
|
||||
for (const evt of job.feed) sseSend(res, evt.type, evt);
|
||||
sseSend(res, 'snapshot', job.snapshot());
|
||||
if (job.done) { sseSend(res, 'done', job.snapshot()); res.end(); return; }
|
||||
const onEvt = (evt) => sseSend(res, evt.type, evt);
|
||||
job.on('event', onEvt);
|
||||
const ping = setInterval(() => res.write(':ping\n\n'), 20000);
|
||||
req.on('close', () => { job.off('event', onEvt); clearInterval(ping); });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- REPL (real CLI harness session) ----
|
||||
if (req.method === 'POST' && p === '/api/repl') {
|
||||
const session = startRepl();
|
||||
return sendJson(res, 200, { id: session.id });
|
||||
}
|
||||
m = p.match(/^\/api\/repl\/([^/]+)\/input$/);
|
||||
if (req.method === 'POST' && m) {
|
||||
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');
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
m = p.match(/^\/api\/repl\/([^/]+)\/stop$/);
|
||||
if (req.method === 'POST' && m) {
|
||||
const session = replSessions.get(m[1]);
|
||||
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
||||
session.child.kill('SIGTERM');
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
m = p.match(/^\/api\/repl\/([^/]+)\/events$/);
|
||||
if (req.method === 'GET' && m) {
|
||||
const session = replSessions.get(m[1]);
|
||||
if (!session) { res.writeHead(404); return res.end(); }
|
||||
sseInit(res);
|
||||
for (const chunk of session.buffer) sseSend(res, 'data', { chunk });
|
||||
if (session.done) { sseSend(res, 'close', {}); res.end(); return; }
|
||||
const onData = (chunk) => sseSend(res, 'data', { chunk });
|
||||
const onClose = () => { sseSend(res, 'close', {}); res.end(); };
|
||||
session.on('data', onData);
|
||||
session.on('close', onClose);
|
||||
const ping = setInterval(() => res.write(':ping\n\n'), 20000);
|
||||
req.on('close', () => { session.off('data', onData); session.off('close', onClose); clearInterval(ping); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && p === '/api/meta') {
|
||||
return sendJson(res, 200, { version: '4.0.0', binary: BIN, root: ROOT });
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: 'not found' });
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`NeuroSploit v4.0.0 web console → http://localhost:${PORT}`);
|
||||
console.log(` binary : ${BIN || '(not found — build neurosploit-rs first)'}`);
|
||||
console.log(` agents : ${AGENTS_DIR}`);
|
||||
console.log(` runs : ${RUNS_DIR}`);
|
||||
});
|
||||
Reference in New Issue
Block a user