Merge pull request #43 from JoasASantos/feat/v4.0.0-web-ui

feat(4.0.0): web console — lead board + live findings + real CLI REPL
This commit is contained in:
Joas A Santos authored and GitHub committed 2026-08-23 14:27:42 -03:00
commit 51c38c1db3
14 files changed
+2549 -14

No files matched your search

+17 -2
View File
@@ -1,4 +1,4 @@
<h1 align="center">🧠 NeuroSploit v3.6.9</h1>
<h1 align="center">🧠 NeuroSploit v4.0.0</h1>
<p align="center">
<a href="https://trendshift.io/repositories/22624?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-22624" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/22624/daily?language=Python" alt="JoasASantos%2FNeuroSploit | Trendshift" width="250" height="55"/></a>
@@ -12,7 +12,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Version-3.6.9-blue?style=flat-square">
<img src="https://img.shields.io/badge/Version-4.0.0-blue?style=flat-square">
<img src="https://img.shields.io/badge/Harness-Rust%20%7C%20tokio-e6b673?style=flat-square">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square">
<img src="https://img.shields.io/badge/MD%20Agents-435-red?style=flat-square">
@@ -214,6 +214,21 @@ No login? Use an **API key** instead — see [Authentication](#authentication--r
---
## 🖥️ Web console (NEW in v4.0.0)
A browser UI for the same harness: a categorized lead board (toggle agents by category, add
custom leads, `Start Exploitation`), a live structured findings view, run history, and a real
REPL — all driven by spawning the compiled CLI, never a reimplementation of it.
```bash
cd neurosploit-rs && cargo build --release # once
node web/server.js # → http://localhost:4173
```
Zero npm dependencies. Full API reference: **[web/API.md](web/API.md)**.
---
## 🔌 Integrations (GitHub · GitLab · Jira)
Wire NeuroSploit into your SDLC. Toggle from the REPL (`/integrations`) or the CLI
+2 -2
View File
@@ -1,4 +1,4 @@
# NeuroSploit — Tutorial & User Guide (v3.6.9)
# NeuroSploit — Tutorial & User Guide (v4.0.0)
A complete, hands-on guide to installing, configuring and running NeuroSploit —
the autonomous, multi-model penetration-testing harness.
@@ -98,7 +98,7 @@ Agents **degrade gracefully**: if `rustscan` is absent they use `nmap`; if neith
### Verify
```bash
neurosploit --version # neurosploit 3.6.9
neurosploit --version # neurosploit 4.0.0
neurosploit agents # {"vulns":241,...,"ai":30,...,"total":430}
neurosploit models # all providers & models
```
+2 -2
View File
@@ -871,7 +871,7 @@ dependencies = [
[[package]]
name = "neurosploit"
version = "3.6.9"
version = "4.0.0"
dependencies = [
"anyhow",
"clap",
@@ -888,7 +888,7 @@ dependencies = [
[[package]]
name = "neurosploit-harness"
version = "3.6.9"
version = "4.0.0"
dependencies = [
"anyhow",
"futures",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/harness", "app"]
resolver = "2"
[workspace.package]
version = "3.6.9"
version = "4.0.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/JoasASantos/NeuroSploit"
+4 -4
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.9 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
//! NeuroSploit v4.0.0 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
mod repl;
mod tui;
@@ -11,8 +11,8 @@ use std::path::{Path, PathBuf};
#[command(
name = "neurosploit",
version,
about = "NeuroSploit v3.6.9 — multi-model autonomous pentest harness",
long_about = "NeuroSploit v3.6.9 — a Rust multi-model harness that drives a pool of LLMs \
about = "NeuroSploit v4.0.0 — multi-model autonomous pentest harness",
long_about = "NeuroSploit v4.0.0 — a Rust multi-model harness that drives a pool of LLMs \
(API key or local subscription: Claude/Codex/Gemini/Grok/OpenCode/Hermes) to autonomously test a target. \
After recon it INTELLIGENTLY selects only the agents matching the discovered surface, runs \
them in parallel, then validates every finding by cross-model voting before reporting.\n\n\
@@ -765,7 +765,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode:
println!(" │ ua : {ua}");
write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target));
println!(" ┌─ NeuroSploit v3.6.9 · by Joas A Santos & Red Team Leaders");
println!(" ┌─ NeuroSploit v4.0.0 · by Joas A Santos & Red Team Leaders");
println!(" │ run id : {run_id}");
println!(" │ target : {}", cfg.target);
println!(" │ models : {}", cfg.models.join(", "));
+2 -2
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.9 — interactive session (Claude-Code / Codex / Cursor-CLI style).
//! NeuroSploit v4.0.0 — interactive session (Claude-Code / Codex / Cursor-CLI style).
//!
//! Launched when `neurosploit` runs with no subcommand. A persistent REPL with
//! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model
@@ -371,7 +371,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
let backends = harness::installed_cli_backends();
println!("\x1b[1m");
println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗");
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.9");
println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v4.0.0");
println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness");
println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos");
println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders");
+1 -1
View File
@@ -1,4 +1,4 @@
//! NeuroSploit v3.6.9 — TUI "Mission Control" mode.
//! NeuroSploit v4.0.0 — TUI "Mission Control" mode.
//!
//! Concurrent panels that update live while the engagement runs in the
//! background, with a composer input that stays active during execution:
+282
View File
@@ -0,0 +1,282 @@
# 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`).
---
## Providers / models / API keys
### `GET /api/providers`
Static mirror of `crates/harness/src/models.rs` `providers()` — every provider the harness
supports, its models, and whether it's usable via a local CLI subscription login (`kind: "cli"`)
or API key only (`kind: "api"`).
```json
[ { "key": "anthropic", "label": "Anthropic Claude", "kind": "cli", "models": ["claude-opus-5", "..."] } ]
```
### `GET /api/keys`
Which providers currently have an API key set **in this server process's memory** (booleans only
— never the value):
```json
[ { "provider": "anthropic", "set": true }, { "provider": "openai", "set": false } ]
```
### `POST /api/keys`
Body `{ "provider": "anthropic", "key": "sk-..." }`. Stores the key in an in-memory `Map` —
**never written to disk**, lost on server restart. Every subsequent `/api/exploit` and `/api/repl`
child process is spawned with `<provider>.envKey` set from this store (merged over `process.env`).
Omitting `key` (or passing an empty string) clears it. 400 on an unknown provider.
### `DELETE /api/keys/:provider`
Clears one provider's key.
---
## 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, "name": "Keystone – Digital Banking", "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, name, meta, status, findings, assets }` (`name` is the engagement
name set in the wizard, `""` if this run predates that or was started outside the web console). `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
"name": "Keystone – Digital Banking", // engagement name — required by the wizard UI
"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
"auth": "Authorization: Bearer <token>", // target auth header — see Target auth below
"roles": [{ "name": "admin", "header": "Authorization: Bearer ..." }], // multi-identity access-control testing
}
```
### Target auth (`auth` / `roles`)
If `creds` is omitted and either `auth` or `roles` is set, the server writes a minimal
`creds.yaml`-compatible file (matching `neurosploit-rs/creds.example.yaml`'s schema) to
`os.tmpdir()/neurosploit-web/<job-id>.creds.yaml` and passes it via `--creds`. An explicit `creds`
path always wins over `auth`/`roles`. These ephemeral files are not cleaned up automatically —
they live in the OS temp dir, never in the repo.
`name` is not a harness/CLI concept — the server persists a `runId -> name` map to
`.neurosploit/web-engagement-names.json` (keyed on the CLI's own run id, captured from its
"run id : ns-…" log line) so `/api/runs` and `/api/runs/:id` can label a run by its engagement
name, surviving a server restart.
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.
+41
View File
@@ -0,0 +1,41 @@
# NeuroSploit v4.0.0 — web console
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.
- **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.
- **Leads** — the categorized agent picker (435 agents auto-classified) + custom leads.
- **Model & Run** — pick a provider/model from the live catalog, API-key vs. subscription auth
mode, votes/chain-depth/recon intensity.
- **Review** — confirm the plan, then `Start Exploitation` spawns the real CLI.
- **Auth & Keys** (one menu, 🔑 in the sidebar) — target auth header + named roles for
IDOR/BOLA/BFLA testing, per-provider API keys (kept in server memory only, never on disk), and
an explicit `creds.yaml` path override.
- **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.
```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
```
+13
View File
@@ -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"
}
}
+718
View File
@@ -0,0 +1,718 @@
'use strict';
/* NeuroSploit v4.0.0 — web console frontend. Vanilla JS, no build step. */
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const MODE_LABELS = {
run: { target: 'Target URL', help: 'The application to test.', showRepo: false, placeholder: 'https://target.example.com' },
whitebox: { target: 'Source repo / path', help: "A GitHub URL, owner/repo shorthand, or a local path — cloned automatically if it's remote.", showRepo: false, placeholder: 'owner/repo' },
greybox: { target: 'Target URL', help: 'The running application to exploit, alongside the source repo below.', showRepo: true, placeholder: 'https://target.example.com' },
host: { target: 'Target host / IP', help: 'Runs Linux / Windows / Active Directory agents.', showRepo: false, placeholder: '10.0.0.10' },
aitest: { target: 'AI endpoint URL', help: 'A live AI agent, LLM chat, or MCP endpoint (OWASP LLM Top 10).', showRepo: false, placeholder: 'https://target.example.com/chat' },
};
const STEP_COUNT = 5;
const state = {
theme: localStorage.getItem('ns-theme') || 'light',
step: 0,
mode: 'run',
categories: [],
selected: new Set(),
customLeads: [],
filter: 'all',
search: '',
providers: [],
auth: { header: '', roles: [] },
credsPath: '',
keys: [],
runs: [],
currentJob: null,
currentDetailId: null,
detailPoll: null,
replId: null, replEs: null,
};
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function api(path, opts) {
const res = await fetch(path, opts);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `${path} → ${res.status}`);
}
return res.headers.get('content-type')?.includes('json') ? res.json() : res.text();
}
function sevRank(sev) {
const s = (sev || '').toLowerCase();
if (s.includes('crit')) return 0;
if (s.includes('high')) return 1;
if (s.includes('med')) return 2;
if (s.includes('low')) return 3;
return 4;
}
function sevClass(sev) {
return ['sev-critical', 'sev-high', 'sev-medium', 'sev-low', 'sev-info'][sevRank(sev)];
}
function show(el, on) { if (el) el.hidden = !on; }
function toast(msg) { console.log('[ns]', msg); }
// ---------------------------------------------------------------------------
// theme
// ---------------------------------------------------------------------------
function applyTheme() {
document.documentElement.setAttribute('data-theme', state.theme);
$('#btnThemeToggle').textContent = state.theme === 'dark' ? '☀' : '☾';
$('#btnThemeToggle').title = state.theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
}
$('#btnThemeToggle').addEventListener('click', () => {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('ns-theme', state.theme);
applyTheme();
});
// ---------------------------------------------------------------------------
// wizard — step navigation
// ---------------------------------------------------------------------------
function goToStep(n) {
state.step = Math.max(0, Math.min(STEP_COUNT - 1, n));
$$('.step-tab').forEach((tab, i) => {
tab.classList.toggle('active', i === state.step);
tab.classList.toggle('done', i < state.step);
});
$$('.wizard-panel').forEach((panel) => show(panel, Number(panel.dataset.panel) === state.step));
show($('#btnStepBack'), state.step > 0);
show($('#btnStepNext'), state.step < STEP_COUNT - 1);
show($('#btnLaunch'), state.step === STEP_COUNT - 1);
if (state.step === STEP_COUNT - 1) renderReview();
updateWizardSummary();
}
function validateStep(n) {
if (n === 0) {
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; }
}
return true;
}
$('#btnStepNext').addEventListener('click', () => { if (validateStep(state.step)) goToStep(state.step + 1); });
$('#btnStepBack').addEventListener('click', () => goToStep(state.step - 1));
$$('.step-tab').forEach((tab) => tab.addEventListener('click', () => {
const n = Number(tab.dataset.step);
if (n <= state.step || validateStep(state.step)) goToStep(n);
}));
function updateWizardSummary() {
const name = $('#fieldName').value.trim() || '(unnamed)';
const target = $('#fieldTarget').value.trim() || '(not set)';
$('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · <b>${esc(name)}</b> · ${esc(state.mode)} · ${esc(target)}`;
}
$('#fieldName').addEventListener('input', updateWizardSummary);
// mode tiles
function selectMode(mode) {
state.mode = mode;
$$('.mode-tile').forEach((t) => t.classList.toggle('selected', t.dataset.mode === mode));
const cfg = MODE_LABELS[mode];
$('#targetLabel').textContent = cfg.target;
$('#targetHelp').textContent = cfg.help;
$('#fieldTarget').placeholder = cfg.placeholder;
show($('#fieldRepoGroup'), cfg.showRepo);
updateWizardSummary();
}
$$('.mode-tile').forEach((tile) => tile.addEventListener('click', () => selectMode(tile.dataset.mode)));
$('#fieldTarget').addEventListener('input', updateWizardSummary);
// ---------------------------------------------------------------------------
// agents / lead board (step 3)
// ---------------------------------------------------------------------------
async function loadAgents() {
const data = await api('/api/agents');
state.categories = data.categories;
renderBoard();
}
function renderBoard() {
const root = $('#categories');
root.innerHTML = '';
for (const group of state.categories) {
const selCount = group.agents.filter((a) => state.selected.has(a.id)).length;
const card = document.createElement('div');
card.className = 'cat-card';
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');
});
const catToggle = card.querySelector('.cat-toggle');
// A partial selection (some but not all agents on) must look "partial",
// not "off" — an unchecked master switch reads as "category disabled"
// even when most of its agents are still on. Indeterminate = the middle
// state; clicking it from there selects everything (browser default).
catToggle.indeterminate = selCount > 0 && selCount < group.agents.length;
catToggle.addEventListener('change', (e) => {
const on = e.target.checked;
for (const a of group.agents) { if (on) state.selected.add(a.id); else state.selected.delete(a.id); }
renderBoard();
});
rows.querySelectorAll('.agent-toggle').forEach((input) => {
input.addEventListener('change', (e) => {
const id = e.target.dataset.id;
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
renderBoard();
});
});
root.appendChild(card);
}
updateChips();
applyFilters();
}
function allAgents() { return state.categories.flatMap((g) => g.agents); }
function updateChips() {
const total = allAgents().length;
$('#chipAll').textContent = total;
$('#chipSelected').textContent = state.selected.size;
$('#chipExcluded').textContent = total - state.selected.size;
}
function applyFilters() {
const q = state.search.trim().toLowerCase();
$$('.agent-row').forEach((row) => {
const isSel = state.selected.has(row.dataset.id);
let visible = true;
if (state.filter === 'selected') visible = isSel;
if (state.filter === 'excluded') visible = !isSel;
if (visible && q) visible = row.dataset.title.includes(q);
row.classList.toggle('hidden-by-search', !visible);
});
$$('.cat-card').forEach((card) => {
const anyVisible = $$('.agent-row', card).some((r) => !r.classList.contains('hidden-by-search'));
card.style.display = anyVisible ? '' : 'none';
});
}
$$('.chip').forEach((chip) => chip.addEventListener('click', () => {
$$('.chip').forEach((c) => c.classList.remove('chip-active'));
chip.classList.add('chip-active');
state.filter = chip.dataset.filter;
applyFilters();
}));
$('#leadSearch').addEventListener('input', (e) => { state.search = e.target.value; applyFilters(); });
function renderCustomLeads() {
const root = $('#customLeadsList');
root.innerHTML = state.customLeads.map((text, i) => `
<div class="custom-lead-chip"><span>${esc(text)}</span><span class="x" data-i="${i}">✕</span></div>
`).join('');
$$('.custom-lead-chip .x', root).forEach((x) => x.addEventListener('click', () => {
state.customLeads.splice(Number(x.dataset.i), 1);
renderCustomLeads();
}));
}
$('#btnCustomLead').addEventListener('click', () => {
const text = prompt('Describe the custom lead (free text — becomes agent focus context):');
if (text && text.trim()) { state.customLeads.push(text.trim()); renderCustomLeads(); }
});
// ---------------------------------------------------------------------------
// providers / model (step 4)
// ---------------------------------------------------------------------------
async function loadProviders() {
state.providers = await api('/api/providers');
const sel = $('#fieldProvider');
sel.innerHTML = state.providers.map((p) => `<option value="${esc(p.key)}">${esc(p.label)} (${p.kind === 'cli' ? 'API or subscription' : 'API key only'})</option>`).join('');
sel.addEventListener('change', onProviderChange);
onProviderChange();
}
function onProviderChange() {
const p = state.providers.find((x) => x.key === $('#fieldProvider').value) || state.providers[0];
const modelSel = $('#fieldModelSelect');
modelSel.innerHTML = (p?.models || []).map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join('');
const subBtn = $('#authModeToggle button[data-mode="subscription"]');
const supportsSub = p?.kind === 'cli';
subBtn.disabled = !supportsSub;
subBtn.title = supportsSub ? '' : `${p?.label} has no local CLI subscription mode — API key only.`;
if (!supportsSub) setAuthMode('api');
updateAuthModeHelp();
}
function setAuthMode(mode) {
$$('#authModeToggle button').forEach((b) => b.classList.toggle('selected', b.dataset.mode === mode));
state.authMode = mode;
updateAuthModeHelp();
}
function updateAuthModeHelp() {
const p = state.providers.find((x) => x.key === $('#fieldProvider').value);
$('#authModeHelp').textContent = state.authMode === 'subscription'
? `Uses the locally logged-in ${p?.label || ''} CLI on this machine — no API key needed.`
: `Uses the API key set for ${p?.label || 'this provider'} in Auth & Keys.`;
}
$$('#authModeToggle button').forEach((b) => b.addEventListener('click', () => { if (!b.disabled) setAuthMode(b.dataset.mode); }));
state.authMode = 'api';
// ---------------------------------------------------------------------------
// review (step 5)
// ---------------------------------------------------------------------------
function renderReview() {
const target = $('#fieldTarget').value.trim();
const repo = $('#fieldRepo').value.trim();
const provider = $('#fieldProvider').value;
const model = $('#fieldModelSelect').value;
const items = [
{ k: 'Engagement name', v: $('#fieldName').value.trim() || '(not set)' },
{ k: 'Mode', v: state.mode },
{ k: MODE_LABELS[state.mode].target, v: target || '(not set)', mono: true },
...(MODE_LABELS[state.mode].showRepo ? [{ k: 'Source repo', v: repo || '(not set)', mono: true }] : []),
{ k: 'Model', v: `${provider}:${model}` },
{ k: 'Auth mode', v: state.authMode === 'subscription' ? 'Subscription (local CLI)' : 'API key' },
{ k: 'Leads selected', v: `${state.selected.size} of ${allAgents().length}${state.selected.size === 0 ? ' — auto (recon-driven)' : ''}` },
{ k: 'Custom leads', v: String(state.customLeads.length) },
{ k: 'Votes / chain / recon', v: `${$('#fieldVotes').value} / ${$('#fieldChain').value} / ${$('#fieldRecon').value}` },
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
];
$('#reviewGrid').innerHTML = items.map((it) => `
<div class="review-item"><div class="k">${esc(it.k)}</div><div class="v${it.mono ? ' mono' : ''}">${esc(it.v)}</div></div>
`).join('');
}
// ---------------------------------------------------------------------------
// launch
// ---------------------------------------------------------------------------
$('#btnLaunch').addEventListener('click', startExploitation);
async function startExploitation() {
if (!validateStep(0)) { goToStep(0); return; }
const mode = state.mode;
const name = $('#fieldName').value.trim();
const target = $('#fieldTarget').value.trim();
const repo = $('#fieldRepo').value.trim();
const provider = $('#fieldProvider').value;
const model = $('#fieldModelSelect').value;
const focusParts = [$('#fieldFocus').value.trim(), ...state.customLeads].filter(Boolean);
const body = {
mode,
name,
target: mode === 'whitebox' ? undefined : target,
repo: mode === 'whitebox' ? target : (repo || undefined),
models: provider && model ? [`${provider}:${model}`] : [],
votes: Number($('#fieldVotes').value) || 3,
chainDepth: Number($('#fieldChain').value),
recon: Number($('#fieldRecon').value),
subscription: state.authMode === 'subscription',
mcp: $('#fieldMcp').checked,
agents: [...state.selected],
focus: focusParts.join('; ') || undefined,
objective: $('#fieldObjective').value.trim() || undefined,
outOfScope: $('#fieldOutOfScope').value.trim() || undefined,
auth: state.auth.header || undefined,
roles: state.auth.roles.length ? state.auth.roles : undefined,
creds: state.credsPath || undefined,
};
$('#btnLaunch').disabled = true;
$('#btnLaunch').textContent = 'Starting…';
try {
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
attachLiveJob(id, body.target || body.repo, name);
} catch (e) {
alert('Failed to start: ' + e.message);
} finally {
$('#btnLaunch').disabled = false;
$('#btnLaunch').textContent = 'Start Exploitation →';
}
}
// ---------------------------------------------------------------------------
// live run view
// ---------------------------------------------------------------------------
function bindRunTabs(scopeEl) {
$$('.run-tab', scopeEl).forEach((tab) => tab.addEventListener('click', () => {
$$('.run-tab', scopeEl).forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
$$('.run-tab-panel', scopeEl).forEach((p) => show(p, p.dataset.tabpanel === tab.dataset.tab));
}));
}
bindRunTabs($('#liveView'));
bindRunTabs($('#detailView'));
function attachLiveJob(id, target, name) {
if (state.currentJob?.es) state.currentJob.es.close();
state.currentJob = { id, es: null, findings: [], target, name, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null, runId: null };
show($('#wizardView'), false);
show($('#detailView'), false);
show($('#liveView'), true);
$('#liveTarget').textContent = name || target || '—';
$('#liveTargetSub').textContent = name ? target : '';
$('#livePhase').textContent = 'starting';
$('#phaseDot').style.background = '';
$('#liveFindingsTable tbody').innerHTML = '';
$('#liveAttackPath').innerHTML = '';
$('#logList').innerHTML = '';
$('#liveFindingsCount').textContent = '0';
show($('#liveFindingsEmpty'), true);
$('#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)); es.close(); refreshRuns(); });
es.onerror = () => { /* EventSource auto-retries; the server replays its buffer on reconnect */ };
}
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 findingRow(f) {
return `<tr>
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
<td>${esc(f.title)}</td>
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
<td>${esc(f.cwe)}</td>
<td>${esc(f.agent)}</td>
<td class="col-conf">${f.confidence ? f.confidence.toFixed(2) : '—'}</td>
</tr>`;
}
function addFinding(f) {
state.currentJob.findings.push(f);
$('#liveFindingsTable tbody').insertAdjacentHTML('beforeend', findingRow(f));
$('#liveFindingsCount').textContent = state.currentJob.findings.length;
show($('#liveFindingsEmpty'), false);
renderAttackPath($('#liveAttackPath'), state.currentJob.findings);
}
function applySnapshot(snap) {
$('#livePhase').textContent = snap.phase;
state.currentJob.runId = snap.runId;
$('#progressLabel').textContent = `${snap.agentsDone} / ${snap.agents || '?'} agents`;
if (snap.agents) $('#progressFill').style.width = `${Math.min(100, (snap.agentsDone / snap.agents) * 100)}%`;
if (snap.reportUrl && snap.runId) {
$('#btnOpenReport').href = `/api/runs/${snap.runId}/asset/report.html`;
show($('#btnOpenReport'), true);
}
if (snap.done) $('#phaseDot').classList.add('static');
}
$('#btnStopRun').addEventListener('click', async () => {
if (!state.currentJob) return;
await api(`/api/exploit/${state.currentJob.id}/stop`, { method: 'POST' });
});
$('#btnBackToBoard').addEventListener('click', () => { show($('#liveView'), false); show($('#wizardView'), true); });
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#wizardView'), true); });
$('#btnNewEngagement').addEventListener('click', () => { clearInterval(state.detailPoll); show($('#detailView'), false); show($('#liveView'), false); show($('#wizardView'), true); });
// ---------------------------------------------------------------------------
// Generative Attack Path Chaining
// ---------------------------------------------------------------------------
const KILL_CHAIN_STAGES = ['recon', 'initial-access', 'execution', 'privesc', 'lateral', 'exfil', 'impact'];
function renderAttackPath(container, findings) {
if (!findings.length) {
container.innerHTML = '<div class="attackpath-empty">The attack path builds automatically as findings chain together — nothing confirmed yet.</div>';
return;
}
const byId = new Map(findings.map((f) => [f.id, f]));
const hasStages = findings.some((f) => f.stage);
let groups;
if (hasStages) {
groups = KILL_CHAIN_STAGES
.map((stage) => ({ label: stage.replace('-', ' '), items: findings.filter((f) => (f.stage || '') === stage) }))
.filter((g) => g.items.length);
const other = findings.filter((f) => !f.stage);
if (other.length) groups.push({ label: 'unstaged', items: other });
} else {
const order = ['critical', 'high', 'medium', 'low', 'info'];
groups = order
.map((sev) => ({ label: sev, items: findings.filter((f) => (f.severity || '').toLowerCase().includes(sev)) }))
.filter((g) => g.items.length);
}
container.innerHTML = `
${!hasStages ? '<div class="field-help" style="margin-bottom:8px;">No kill-chain stage data yet — grouped by severity.</div>' : ''}
<div class="attackpath">
${groups.map((g, i) => `
${i > 0 ? '<div class="ap-arrow">→</div>' : ''}
<div class="ap-stage">
<div class="ap-stage-head">${esc(g.label)} (${g.items.length})</div>
${g.items.map((f) => `
<div class="ap-node ${sevClass(f.severity)}">
<div class="t">${esc(f.title)}</div>
<div class="m">${esc(f.mitre || f.owasp || f.cwe || '')}</div>
${(f.chains_from || []).length ? `<div class="chain-from">⤷ chains from ${(f.chains_from).map((cid) => esc(byId.get(cid)?.title || cid)).join(', ')}</div>` : ''}
</div>
`).join('')}
</div>
`).join('')}
</div>
`;
}
// ---------------------------------------------------------------------------
// sidebar — runs history
// ---------------------------------------------------------------------------
async function refreshRuns() {
try { state.runs = await api('/api/runs'); } catch { state.runs = []; }
renderSidebar();
}
const PHASE_ORDER = { starting: 0, recon: 0, planning: 1, exploiting: 2, validating: 2, chaining: 2, complete: 3 };
function stepClassFor(phase, step) {
const order = ['recon', 'planning', 'exploiting', 'remediation'];
if (step === 'remediation') return 'pending'; // not automated yet
const idx = PHASE_ORDER[phase] ?? 0;
const stepIdx = order.indexOf(step);
if (stepIdx < idx) return 'done';
if (stepIdx === idx) return 'active';
return 'pending';
}
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 }, { label: 'Completed', items: completed }];
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 = `<span class="name">${esc(r.name || r.target)}</span><span class="sub">${r.name ? esc(r.target) + ' · ' : ''}${r.findings} finding(s)</span>`;
btn.addEventListener('click', () => openRun(r));
items.appendChild(btn);
const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId;
if (isThisJob) {
const steps = document.createElement('div');
steps.className = 'sb-steps';
steps.innerHTML = ['recon', 'planning', 'exploiting', 'remediation'].map((s) =>
`<div class="sb-step ${stepClassFor($('#livePhase').textContent, 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 && run.id === state.currentJob.runId) {
show($('#wizardView'), false); show($('#detailView'), false); show($('#liveView'), true);
renderSidebar();
return;
}
show($('#wizardView'), 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)}`);
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;
const tbody = $('#detailFindingsTable tbody');
tbody.innerHTML = detail.findings.map(findingRow).join('');
show($('#detailFindingsEmpty'), detail.findings.length === 0);
renderAttackPath($('#detailAttackPath'), detail.findings);
const reportLink = $('#detailOpenReport');
if (detail.assets.includes('report.html')) {
reportLink.href = `/api/runs/${encodeURIComponent(id)}/asset/report.html`;
show(reportLink, true);
} else show(reportLink, false);
if (detail.status?.state === 'running') state.detailPoll = setInterval(() => loadDetail(id), 4000);
}
// ---------------------------------------------------------------------------
// Auth & Keys modal
// ---------------------------------------------------------------------------
function openAuthModal() {
show($('#authModal'), true);
renderRoleList();
$('#credsPath').value = state.credsPath;
refreshKeyStatus();
}
['#btnOpenAuth', '#btnOpenAuth2', '#btnOpenAuth3'].forEach((sel) => $(sel)?.addEventListener('click', openAuthModal));
$('#btnCloseAuth').addEventListener('click', () => show($('#authModal'), false));
$('#authModal').addEventListener('click', (e) => { if (e.target.id === 'authModal') show($('#authModal'), false); });
$$('.modal-tab').forEach((tab) => tab.addEventListener('click', () => {
$$('.modal-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
$$('.modal-panel').forEach((p) => show(p, p.dataset.mpanel === tab.dataset.mtab));
}));
$('#authHeader').addEventListener('input', (e) => { state.auth.header = e.target.value.trim(); });
$('#authHeader').value = state.auth.header;
$('#credsPath').addEventListener('input', (e) => { state.credsPath = e.target.value.trim(); });
function renderRoleList() {
const root = $('#roleList');
root.innerHTML = state.auth.roles.map((r, i) => `
<div class="role-row">
<input class="role-name" data-i="${i}" data-f="name" placeholder="role name" value="${esc(r.name)}" />
<input data-i="${i}" data-f="header" placeholder="Authorization: Bearer ..." value="${esc(r.header)}" />
<button class="icon-btn" data-i="${i}" data-remove>✕</button>
</div>
`).join('');
$$('input[data-f]', root).forEach((inp) => inp.addEventListener('input', (e) => {
state.auth.roles[Number(e.target.dataset.i)][e.target.dataset.f] = e.target.value;
}));
$$('[data-remove]', root).forEach((btn) => btn.addEventListener('click', () => {
state.auth.roles.splice(Number(btn.dataset.i), 1);
renderRoleList();
}));
}
$('#btnAddRole').addEventListener('click', () => { state.auth.roles.push({ name: '', header: '' }); renderRoleList(); });
async function refreshKeyStatus() {
try { state.keys = await api('/api/keys'); } catch { state.keys = []; }
const root = $('#providerKeyList');
root.innerHTML = state.providers.map((p) => {
const set = state.keys.find((k) => k.provider === p.key)?.set;
return `
<div class="provider-row">
<span class="dot ${set ? 'set' : ''}"></span>
<span class="p-name">${esc(p.label)}</span>
<span class="p-kind">${p.kind === 'cli' ? 'cli+api' : 'api only'}</span>
<input type="password" data-provider="${esc(p.key)}" placeholder="${set ? '•••••••• (set — enter to replace)' : 'paste API key'}" />
<button class="btn btn-sm" data-save="${esc(p.key)}">Save</button>
</div>
`;
}).join('');
$$('[data-save]', root).forEach((btn) => btn.addEventListener('click', async () => {
const provider = btn.dataset.save;
const input = root.querySelector(`input[data-provider="${provider}"]`);
const key = input.value.trim();
if (!key) return;
await api('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, key }) });
input.value = '';
refreshKeyStatus();
}));
}
// ---------------------------------------------------------------------------
// REPL drawer — real CLI harness session
// ---------------------------------------------------------------------------
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.appendChild(document.createTextNode(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');
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 }) });
});
// ---------------------------------------------------------------------------
// boot
// ---------------------------------------------------------------------------
async function boot() {
applyTheme();
selectMode('run');
goToStep(0);
renderCustomLeads();
const meta = await api('/api/meta').catch(() => ({}));
$('#sbVersion').textContent = `v${meta.version || '4.0.0'}`;
await Promise.all([loadAgents(), loadProviders()]);
await refreshRuns();
setInterval(refreshRuns, 6000);
}
boot();
+317
View File
@@ -0,0 +1,317 @@
<!doctype html>
<html lang="en">
<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" id="sidebar">
<div class="sb-top">
<div class="brand"><span class="mark">NS</span> NeuroSploit</div>
<button class="icon-btn" id="btnThemeToggle" title="Toggle light / dark theme">☾</button>
</div>
<button class="sb-new" id="btnNewEngagement">+ New engagement</button>
<div class="sb-groups" id="sbGroups"><!-- populated by app.js --></div>
<div class="sb-bottom">
<span class="sb-version" id="sbVersion">v4.0.0</span>
<div class="sb-bottom-actions">
<button class="icon-btn" id="btnOpenAuth" title="Auth &amp; API keys">🔑</button>
<button class="icon-btn" id="btnOpenRepl" title="Open REPL">❭_</button>
</div>
</div>
</aside>
<!-- ============ MAIN ============ -->
<main class="main">
<!-- ============ WIZARD (new engagement) ============ -->
<section class="wizard" id="wizardView">
<header class="topbar">
<div>
<div class="topbar-title">New engagement</div>
<div class="topbar-sub">Asset → Scope &amp; Auth → Leads → Model &amp; Run → Review</div>
</div>
<div class="topbar-spacer"></div>
<button class="btn" id="btnOpenAuth2">🔑 Auth &amp; Keys</button>
</header>
<nav class="stepper" id="stepper">
<button class="step-tab active" data-step="0"><span class="n">1</span> Asset</button>
<button class="step-tab" data-step="1"><span class="n">2</span> Scope &amp; Auth</button>
<button class="step-tab" data-step="2"><span class="n">3</span> Leads</button>
<button class="step-tab" data-step="3"><span class="n">4</span> Model &amp; Run</button>
<button class="step-tab" data-step="4"><span class="n">5</span> Review</button>
</nav>
<div class="wizard-body">
<!-- 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" />
<div class="field-help">Identifies this engagement in the sidebar and run history — required.</div>
</div>
<div>
<div class="section-title">What are you testing?</div>
<div class="section-desc">Pick the engagement type — this decides which CLI subcommand runs underneath.</div>
</div>
<div class="mode-tiles" id="modeTiles">
<button class="mode-tile" data-mode="run"><span class="t">Black-box</span><span class="d">URL only — recon-driven</span></button>
<button class="mode-tile" data-mode="whitebox"><span class="t">White-box</span><span class="d">Source repo — SAST agents</span></button>
<button class="mode-tile" data-mode="greybox"><span class="t">Grey-box</span><span class="d">URL + source together</span></button>
<button class="mode-tile" data-mode="host"><span class="t">Host / Infra</span><span class="d">IP — Linux/Windows/AD</span></button>
<button class="mode-tile" data-mode="aitest"><span class="t">AI / LLM</span><span class="d">Live AI agent or MCP endpoint</span></button>
</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" />
<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" />
<div class="field-help">Cloned automatically if it's a GitHub URL or owner/repo shorthand.</div>
</div>
</div>
<!-- Step 2 — Scope & Auth -->
<div class="wizard-panel" data-panel="1" hidden>
<div>
<div class="section-title">Objective &amp; focus</div>
<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>
<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>
<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>
<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 &amp; Keys</button> menu.</div>
</div>
</div>
<!-- Step 3 — Leads -->
<div class="wizard-panel" data-panel="2" hidden style="max-width: none;">
<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" />
</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-sm" id="btnCustomLead">+ Custom lead</button>
</div>
<div class="custom-leads" id="customLeadsList"></div>
<div class="categories" id="categories"><!-- populated --></div>
</div>
<!-- Step 4 — Model & Run -->
<div class="wizard-panel" data-panel="3" hidden>
<div>
<div class="section-title">Model</div>
<div class="section-desc">Pick a provider and model from the harness's live catalog.</div>
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label">Provider</label>
<select id="fieldProvider"></select>
</div>
<div class="field-group">
<label class="field-label">Model</label>
<select id="fieldModelSelect"></select>
</div>
</div>
<div class="field-group">
<label class="field-label">Auth mode</label>
<div class="auth-mode-toggle" id="authModeToggle">
<button data-mode="api" class="selected">API key</button>
<button data-mode="subscription">Subscription (local CLI login)</button>
</div>
<div class="field-help" id="authModeHelp">Uses the API key set in Auth &amp; Keys for this provider.</div>
</div>
<div class="check-row"><input type="checkbox" id="fieldMcp" /> <label for="fieldMcp">Playwright MCP (browser tool access, subscription backends only)</label></div>
<div>
<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>
<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>
</div>
</div>
<!-- Step 5 — Review -->
<div class="wizard-panel" data-panel="4" hidden>
<div>
<div class="section-title">Review</div>
<div class="section-desc">Confirm before launching — this spawns the real CLI harness.</div>
</div>
<div class="review-grid" id="reviewGrid"></div>
</div>
</div>
<footer class="wizard-footer">
<div class="summary-line" id="wizardSummary"></div>
<div style="display:flex; gap:8px;">
<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>
</div>
</footer>
</section>
<!-- ============ LIVE RUN ============ -->
<section class="runpage" id="liveView" hidden>
<header class="run-head">
<div>
<div class="run-target" id="liveTarget">—</div>
<div class="run-meta" id="liveTargetSub" style="font-family: var(--mono);"></div>
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
</div>
<div class="run-actions">
<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>
</div>
</header>
<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>
<nav class="run-tabs">
<button class="run-tab active" data-tab="findings">Findings <span id="liveFindingsCount">0</span></button>
<button class="run-tab" data-tab="attackpath">Generative Attack Path Chaining</button>
<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="attackpath" hidden><div id="liveAttackPath"></div></div>
<div class="run-tab-panel" data-tabpanel="log" hidden><div class="log-panel" id="logList" style="height: 100%;"></div></div>
</div>
</section>
<!-- ============ RUN DETAIL (past run) ============ -->
<section class="runpage" id="detailView" hidden>
<header class="run-head">
<div>
<div class="run-target" id="detailTarget">—</div>
<div class="run-meta" id="detailTargetSub" style="font-family: var(--mono);"></div>
<div class="run-meta"><span class="phase-dot static" id="detailDot"></span><span id="detailState">—</span></div>
</div>
<div class="run-actions">
<a class="btn" id="detailOpenReport" target="_blank" hidden>Open report</a>
<button class="btn" id="btnDetailBack">← New engagement</button>
</div>
</header>
<nav class="run-tabs">
<button class="run-tab active" data-tab="findings">Findings <span id="detailFindingsCount">0</span></button>
<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="attackpath" hidden><div id="detailAttackPath"></div></div>
</div>
</section>
</main>
</div>
<!-- ============ AUTH & KEYS MODAL ============ -->
<div class="modal-overlay" id="authModal" hidden>
<div class="modal">
<div class="modal-head">
<div class="title">Auth &amp; Keys</div>
<button class="icon-btn" id="btnCloseAuth">✕</button>
</div>
<div class="modal-tabs">
<button class="modal-tab active" data-mtab="target">Target auth</button>
<button class="modal-tab" data-mtab="keys">API keys</button>
<button class="modal-tab" data-mtab="creds">Creds file</button>
</div>
<div class="modal-body">
<div class="modal-panel" data-mpanel="target">
<div class="field-group">
<label class="field-label">Auth header</label>
<input id="authHeader" type="text" placeholder="Authorization: Bearer &lt;token&gt; 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>
<div class="field-group">
<label class="field-label">Named roles (multi-identity access-control testing)</label>
<div class="role-list" id="roleList"></div>
<button class="btn btn-sm" id="btnAddRole" style="align-self:flex-start;">+ Add role</button>
<div class="field-help">Two or more roles enable IDOR/BOLA/BFLA cross-role testing.</div>
</div>
</div>
<div class="modal-panel" data-mpanel="keys" hidden>
<div class="field-help" style="margin-bottom:12px;">Keys are kept in this server process's memory only — never written to disk. Cleared on restart.</div>
<div id="providerKeyList"></div>
</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>
<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>
</div>
</div>
</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>
<script src="/app.js"></script>
</body>
</html>
+398
View File
@@ -0,0 +1,398 @@
/* NeuroSploit v4.0.0 — web console.
Visual direction: dense security-operations console (not a marketing SaaS
page). Borders over shadows, typography over color, two radii, one accent.
*/
:root {
/* spacing scale — 4/8/12/16/24/32/48/64 */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
--radius-sm: 6px;
--radius-md: 10px;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
--mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
/* light (default, explicit) */
--bg: #f5f4f1;
--surface: #ffffff;
--surface-2: #faf9f6;
--surface-3: #efeeea;
--border: #e1dfd8;
--border-strong: #cfccc3;
--text: #17161a;
--text-dim: #6b6862;
--text-faint: #9c988f;
--accent: #b5590a;
--accent-hover: #9a4b07;
--accent-contrast: #fff8f0;
--accent-soft: #fbe9d8;
--focus-ring: #b5590a;
--sev-critical-bg: #fbe0dc; --sev-critical-fg: #8a271a;
--sev-high-bg: #fbe6cf; --sev-high-fg: #8a4a0a;
--sev-medium-bg: #f8edc9; --sev-medium-fg: #715600;
--sev-low-bg: #dcecdd; --sev-low-fg: #235c34;
--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);
}
:root[data-theme="dark"] {
--bg: #121113;
--surface: #191819;
--surface-2: #1f1e20;
--surface-3: #262427;
--border: #2d2b2e;
--border-strong: #3c3a3d;
--text: #ece9e4;
--text-dim: #a19d95;
--text-faint: #6f6b64;
--accent: #e08a3e;
--accent-hover: #ec9c57;
--accent-contrast: #1a1208;
--accent-soft: #3a2a16;
--focus-ring: #e08a3e;
--sev-critical-bg: #3a1c17; --sev-critical-fg: #f0968a;
--sev-high-bg: #3a2914; --sev-high-fg: #eeae6a;
--sev-medium-bg: #362c10; --sev-medium-fg: #e2c568;
--sev-low-bg: #17301f; --sev-low-fg: #78c896;
--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);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background: var(--bg); color: var(--text); font-family: var(--sans);
font-size: 13px; line-height: 1.5; -webkit-font-smoothing: antialiased;
}
button { font-family: inherit; cursor: pointer; }
input, select, textarea { font-family: inherit; color: inherit; font-size: 13px; }
a { color: var(--accent); text-decoration: none; }
:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
/* ============================================================ AppShell */
.app { display: flex; height: 100vh; overflow: hidden; }
.sidebar {
width: 248px; flex: none; background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sb-top {
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-4) var(--sp-4) var(--sp-3);
}
.brand { display: flex; align-items: center; gap: var(--sp-2); font-weight: 600; font-size: 13px; letter-spacing: .01em; flex: 1; }
.brand .mark { width: 22px; height: 22px; border-radius: var(--radius-sm); background: var(--accent); color: var(--accent-contrast); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; font-family: var(--mono); }
.icon-btn {
background: transparent; border: 1px solid transparent; color: var(--text-dim);
width: 26px; height: 26px; border-radius: var(--radius-sm); font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.icon-btn:hover { background: var(--surface-3); border-color: var(--border); color: var(--text); }
.sb-new {
margin: 0 var(--sp-4) var(--sp-4); padding: var(--sp-3) var(--sp-3);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
background: var(--surface); color: var(--text); font-size: 12.5px; font-weight: 600; text-align: left;
}
.sb-new:hover { border-color: var(--accent); color: var(--accent); }
.sb-groups { flex: 1; overflow-y: auto; padding: 0 var(--sp-2) var(--sp-4); }
.sb-group-head {
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-2);
font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-faint);
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.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-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); }
.sb-step::before { content: "○"; font-size: 9px; width: 10px; }
.sb-step.done { color: var(--text-dim); }
.sb-step.done::before { content: "●"; color: var(--sev-low-fg); }
.sb-step.active { color: var(--accent); font-weight: 600; }
.sb-step.active::before { content: "◐"; color: var(--accent); }
.sb-bottom { border-top: 1px solid var(--border); padding: var(--sp-3) var(--sp-4); display: flex; align-items: center; justify-content: space-between; }
.sb-version { font-size: 11px; color: var(--text-faint); font-family: var(--mono); }
.sb-bottom-actions { display: flex; gap: var(--sp-1); }
/* ============================================================ Main / Topbar */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-3) var(--sp-5);
border-bottom: 1px solid var(--border); background: var(--bg); min-height: 56px;
}
.topbar-title { font-size: 15px; font-weight: 600; }
.topbar-sub { font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-top: 1px; }
.topbar-spacer { flex: 1; }
.btn {
border-radius: var(--radius-sm); border: 1px solid var(--border-strong); padding: var(--sp-2) var(--sp-4);
font-size: 12.5px; font-weight: 500; background: var(--surface); color: var(--text);
display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap;
}
.btn:hover { border-color: var(--text-dim); }
.btn-sm { padding: 6px var(--sp-3); font-size: 12px; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); font-weight: 600; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger { background: transparent; border-color: var(--sev-critical-fg); color: var(--sev-critical-fg); }
.btn-danger:hover { background: var(--sev-critical-bg); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn:focus-visible { outline-offset: 2px; }
/* ============================================================ Wizard */
.wizard { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.stepper {
display: flex; align-items: center; padding: 0 var(--sp-5); border-bottom: 1px solid var(--border);
background: var(--surface); overflow-x: auto;
}
.step-tab {
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-4) var(--sp-4) var(--sp-3);
border-bottom: 2px solid transparent; color: var(--text-faint); font-size: 12.5px; font-weight: 500;
background: transparent; border-top: none; border-left: none; border-right: none; white-space: nowrap;
}
.step-tab .n { font-family: var(--mono); font-size: 11px; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border-strong); display: flex; align-items: center; justify-content: center; }
.step-tab.done .n { background: var(--sev-low-fg); border-color: var(--sev-low-fg); color: #fff; }
.step-tab.done .n::before { content: "✓"; }
.step-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.step-tab.active .n { border-color: var(--accent); color: var(--accent); }
.step-tab:disabled { cursor: default; }
.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[hidden] { display: none; }
.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-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
.field-row > * { flex: 1; min-width: 160px; }
.section-title { font-size: 13px; font-weight: 600; }
.section-desc { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
input[type="text"], input[type="number"], input[type="password"], select, textarea {
border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px;
background: var(--surface); color: var(--text); width: 100%;
}
input:focus-visible, select:focus-visible, textarea:focus-visible { border-color: var(--accent); }
textarea { resize: vertical; min-height: 72px; }
.narrow { max-width: 120px; }
.mode-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--sp-3); }
.mode-tile {
border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--sp-3) var(--sp-3);
background: var(--surface); text-align: left; display: flex; flex-direction: column; gap: 2px;
}
.mode-tile .t { font-weight: 600; font-size: 12.5px; }
.mode-tile .d { font-size: 11px; color: var(--text-faint); }
.mode-tile:hover { border-color: var(--text-dim); }
.mode-tile.selected { border-color: var(--accent); background: var(--accent-soft); }
.auth-mode-toggle { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.auth-mode-toggle button { border: none; background: var(--surface); padding: 8px 14px; font-size: 12.5px; color: var(--text-dim); }
.auth-mode-toggle button.selected { background: var(--accent); color: var(--accent-contrast); font-weight: 600; }
.auth-mode-toggle button:disabled { opacity: .45; cursor: not-allowed; }
.check-row { display: flex; align-items: center; gap: var(--sp-2); font-size: 12.5px; color: var(--text-dim); }
.role-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.role-row { display: flex; gap: var(--sp-2); align-items: center; }
.role-row input { flex: 1; }
.role-row input.role-name { max-width: 140px; flex: none; }
/* 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; }
.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); }
.chip-active { border-color: var(--accent); color: var(--accent); }
.chip-active span { color: var(--accent); }
.categories { display: flex; flex-direction: column; gap: var(--sp-2); }
.cat-card { border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.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-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); }
.agent-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-3); border-bottom: 1px solid var(--border); font-size: 12.5px; }
.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: 10.5px; color: var(--text-faint); font-family: var(--mono); }
.switch { position: relative; width: 30px; height: 17px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .track { position: absolute; inset: 0; background: var(--border-strong); border-radius: 999px; transition: background .15s; }
.switch .thumb { position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--surface); transition: transform .15s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track + .thumb { transform: translateX(13px); }
/* partial selection (some agents on, not all) — reads as "partial", not "off" */
.switch input:indeterminate + .track { background: var(--border-strong); }
.switch input:indeterminate + .track + .thumb { transform: translateX(7px); background: var(--accent); }
.custom-leads { display: flex; flex-direction: column; gap: var(--sp-2); }
.custom-lead-chip { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px var(--sp-3); font-size: 12px; background: var(--surface-2); }
.custom-lead-chip .x { margin-left: auto; color: var(--text-faint); }
.custom-lead-chip .x:hover { color: var(--sev-critical-fg); }
.wizard-footer {
display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5);
border-top: 1px solid var(--border); background: var(--surface);
}
.summary-line { font-size: 11.5px; color: var(--text-faint); }
.summary-line b { color: var(--text); font-weight: 600; }
.review-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-4); }
.review-item { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); }
.review-item .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-faint); }
.review-item .v { font-size: 13px; margin-top: 3px; font-weight: 500; }
.review-item .v.mono { font-family: var(--mono); font-size: 12px; }
/* ============================================================ Live run / detail */
.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); }
.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} }
.run-actions { display: flex; gap: var(--sp-2); }
.progress-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-5) var(--sp-4); }
.progress-bar { flex: 1; height: 6px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); transition: width .3s; }
.progress-label { font-size: 11.5px; color: var(--text-faint); font-family: var(--mono); white-space: nowrap; }
.run-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
.run-tab { padding: var(--sp-3) var(--sp-3); font-size: 12px; font-weight: 500; color: var(--text-faint); border-bottom: 2px solid transparent; background: none; border-top: none; border-left: none; border-right: none; }
.run-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.run-body { flex: 1; overflow-y: auto; padding: var(--sp-5); }
.run-tab-panel[hidden] { display: none; }
/* Generative Attack Path Chaining */
.attackpath-empty { font-size: 12.5px; color: var(--text-faint); padding: var(--sp-5); text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); }
.attackpath { display: flex; gap: var(--sp-4); overflow-x: auto; padding-bottom: var(--sp-3); }
.ap-stage { flex: none; width: 220px; display: flex; flex-direction: column; gap: var(--sp-2); }
.ap-stage-head { font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-faint); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border); }
.ap-node { border: 1px solid var(--border); border-left: 3px solid var(--text-faint); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); background: var(--surface); font-size: 12px; }
.ap-node.sev-critical { border-left-color: var(--sev-critical-fg); }
.ap-node.sev-high { border-left-color: var(--sev-high-fg); }
.ap-node.sev-medium { border-left-color: var(--sev-medium-fg); }
.ap-node.sev-low { border-left-color: var(--sev-low-fg); }
.ap-node.sev-info { border-left-color: var(--sev-info-fg); }
.ap-node .t { font-weight: 600; }
.ap-node .m { font-size: 10.5px; color: var(--text-faint); margin-top: 3px; font-family: var(--mono); }
.ap-node .chain-from { font-size: 10.5px; color: var(--accent); margin-top: 3px; }
.ap-arrow { flex: none; display: flex; align-items: center; color: var(--text-faint); font-size: 16px; }
/* 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; }
.data-table td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); vertical-align: top; }
.data-table tbody tr:hover { background: var(--surface-2); }
.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; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); letter-spacing: .02em; white-space: nowrap; }
.sev-critical { background: var(--sev-critical-bg); color: var(--sev-critical-fg); }
.sev-high { background: var(--sev-high-bg); color: var(--sev-high-fg); }
.sev-medium { background: var(--sev-medium-bg); color: var(--sev-medium-fg); }
.sev-low { background: var(--sev-low-bg); color: var(--sev-low-fg); }
.sev-info { background: var(--sev-info-bg); color: var(--sev-info-fg); }
.log-panel { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); max-height: 100%; overflow-y: auto; padding: var(--sp-3); }
.log-line { font-family: var(--mono); font-size: 11px; color: var(--text-dim); padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
/* ============================================================ Modal (Auth & Keys) */
.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-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); }
.modal-tab { padding: var(--sp-3) var(--sp-2); font-size: 12px; font-weight: 500; color: var(--text-faint); border-bottom: 2px solid transparent; background: none; border-top: none; border-left: none; border-right: none; }
.modal-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.modal-body { padding: var(--sp-5); overflow-y: auto; flex: 1; }
.modal-panel[hidden] { display: none; }
.provider-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); }
.provider-row:last-child { border-bottom: none; }
.provider-row .p-name { flex: none; width: 150px; font-size: 12.5px; font-weight: 500; }
.provider-row .p-kind { flex: none; width: 74px; font-size: 10px; text-transform: uppercase; color: var(--text-faint); font-family: var(--mono); }
.provider-row input { flex: 1; font-family: var(--mono); font-size: 12px; }
.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 */
.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; }
.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;
}
.fab:hover { background: var(--accent-hover); }
/* ============================================================ Responsive */
@media (max-width: 1024px) {
.sidebar { width: 200px; }
}
@media (max-width: 768px) {
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 55; transition: left .2s; }
.sidebar.open { left: 0; }
.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; }
}
@media (max-width: 480px) {
.topbar { flex-wrap: wrap; gap: var(--sp-2); }
.stepper { padding: 0 var(--sp-3); }
}
+751
View File
@@ -0,0 +1,751 @@
#!/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 os = require('node:os');
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 NAMES_FILE = path.join(ROOT, '.neurosploit', 'web-engagement-names.json');
// Engagement names are set by the operator in the wizard before launch (not
// something the CLI/harness knows about) — persisted here as runId -> name so
// the sidebar/run history can label a run by its engagement name across
// restarts, not just by target/run-id.
const engagementNames = new Map();
try {
const raw = JSON.parse(fs.readFileSync(NAMES_FILE, 'utf8'));
for (const [k, v] of Object.entries(raw)) engagementNames.set(k, v);
} catch { /* no file yet — fine */ }
function saveEngagementName(runId, name) {
if (!runId || !name) return;
engagementNames.set(runId, name);
fsp.mkdir(path.dirname(NAMES_FILE), { recursive: true })
.then(() => fsp.writeFile(NAMES_FILE, JSON.stringify(Object.fromEntries(engagementNames), null, 2)))
.catch(() => {});
}
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);
// ---------------------------------------------------------------------------
// Providers — mirrors crates/harness/src/models.rs `providers()`. Kept as a
// literal table (not parsed from CLI output) so `kind` ("cli" = usable via a
// locally-installed agentic CLI subscription login, "api" = key-only) and
// `envKey` (the environment variable the harness reads for that provider)
// are available without shelling out. Keep this in sync when models.rs adds
// a provider.
// ---------------------------------------------------------------------------
const PROVIDERS = [
{ key: 'anthropic', label: 'Anthropic Claude', kind: 'cli', envKey: 'ANTHROPIC_API_KEY',
models: ['claude-opus-5', 'claude-sonnet-5', 'claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'] },
{ key: 'openai', label: 'OpenAI (ChatGPT)', kind: 'cli', envKey: 'OPENAI_API_KEY',
models: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', 'gpt-5.1', 'gpt-5.1-codex', 'o4'] },
{ key: 'xai', label: 'xAI Grok', kind: 'cli', envKey: 'XAI_API_KEY',
models: ['grok-4.5', 'grok-4', 'grok-4-fast'] },
{ key: 'gemini', label: 'Google Gemini', kind: 'cli', envKey: 'GEMINI_API_KEY',
models: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'] },
{ key: 'opencode', label: 'OpenCode Zen', kind: 'cli', envKey: 'OPENCODE_API_KEY',
models: ['claude-opus-5', 'claude-sonnet-5', 'gpt-5.6-sol', 'gpt-5.5', 'gemini-3-pro', 'grok-4.5', 'deepseek-v4-pro', 'qwen3.7-max', 'kimi-k3'] },
{ key: 'nous', label: 'Nous Research (Hermes)', kind: 'cli', envKey: 'NOUS_API_KEY',
models: ['Hermes-4-405B', 'Hermes-4-70B', 'DeepHermes-3-Mistral-24B-Preview'] },
{ key: 'nvidia_nim', label: 'NVIDIA NIM', kind: 'api', envKey: 'NVIDIA_NIM_API_KEY',
models: ['nvidia/llama-3.3-nemotron-super-49b-v1', 'deepseek-ai/deepseek-r1', 'qwen/qwen2.5-coder-32b-instruct'] },
{ key: 'deepseek', label: 'DeepSeek', kind: 'api', envKey: 'DEEPSEEK_API_KEY',
models: ['deepseek-reasoner', 'deepseek-chat'] },
{ key: 'mistral', label: 'Mistral', kind: 'api', envKey: 'MISTRAL_API_KEY',
models: ['mistral-large-latest', 'codestral-latest'] },
{ key: 'qwen', label: 'Qwen (DashScope)', kind: 'api', envKey: 'DASHSCOPE_API_KEY',
models: ['qwen-max', 'qwen2.5-coder-32b-instruct', 'qwq-plus'] },
{ key: 'groq', label: 'Groq', kind: 'api', envKey: 'GROQ_API_KEY',
models: ['llama-3.3-70b-versatile', 'qwen-2.5-coder-32b'] },
{ key: 'together', label: 'Together AI', kind: 'api', envKey: 'TOGETHER_API_KEY',
models: ['Qwen/Qwen2.5-Coder-32B-Instruct', 'deepseek-ai/DeepSeek-R1', 'meta-llama/Llama-3.3-70B-Instruct-Turbo'] },
{ key: 'moonshot', label: 'Moonshot AI (Kimi)', kind: 'api', envKey: 'MOONSHOT_API_KEY',
models: ['kimi-k3', 'kimi-k2', 'moonshot-v1-128k', 'moonshot-v1-32k'] },
{ key: 'litellm', label: 'LiteLLM (proxy)', kind: 'api', envKey: 'LITELLM_API_KEY',
models: ['gpt-4o', 'claude-3-7-sonnet', 'gemini/gemini-2.5-pro'] },
{ key: 'openrouter', label: 'OpenRouter', kind: 'api', envKey: 'OPENROUTER_API_KEY',
models: ['anthropic/claude-opus-4-8', 'qwen/qwen-2.5-coder-32b-instruct', 'deepseek/deepseek-r1', 'meta-llama/llama-3.3-70b-instruct'] },
{ key: 'azure', label: 'Azure OpenAI', kind: 'api', envKey: 'AZURE_OPENAI_API_KEY',
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-5.1', 'o4-mini'] },
{ key: 'ollama', label: 'Ollama (local)', kind: 'api', envKey: 'OLLAMA_API_KEY',
models: ['qwen2.5-coder:32b', 'qwq:32b', 'deepseek-r1:32b', 'llama3.3:70b'] },
{ key: 'llamacpp', label: 'llama.cpp (local)', kind: 'api', envKey: 'LLAMACPP_API_KEY',
models: ['qwen2.5-coder-32b-instruct', 'dolphin-2.9-llama3-70b', 'deepseek-r1-distill-qwen-32b', 'llama-3.3-70b-instruct'] },
];
// In-memory only — never written to disk. Cleared on server restart.
const apiKeys = new Map(); // provider key -> secret
function envOverrides() {
const env = {};
for (const [key, secret] of apiKeys) {
const p = PROVIDERS.find((x) => x.key === key);
if (p && secret) env[p.envKey] = secret;
}
return env;
}
/// Build a minimal creds.yaml-compatible file (see neurosploit-rs/creds.example.yaml)
/// from a raw auth header and/or named roles, so the CLI's --creds flag can be
/// used to carry web-entered auth material without a real file on disk.
function buildCredsYaml({ auth, roles }) {
const lines = ['# generated by the NeuroSploit web console — ephemeral, not committed'];
if (auth) lines.push(`header: ${JSON.stringify(auth)}`);
for (const r of roles || []) {
if (!r?.name || !r?.header) continue;
const safe = String(r.name).replace(/[^a-zA-Z0-9_-]/g, '_');
lines.push(`${safe}:`, ` header: ${JSON.stringify(r.header)}`);
}
return lines.join('\n') + '\n';
}
async function materializeCreds(body, jobId) {
if (body.creds) return body.creds; // explicit file path on disk wins
if (!body.auth && !(body.roles || []).length) return undefined;
const dir = path.join(os.tmpdir(), 'neurosploit-web');
await fsp.mkdir(dir, { recursive: true });
const file = path.join(dir, `${jobId}.creds.yaml`);
await fsp.writeFile(file, buildCredsYaml(body));
return file;
}
// ---------------------------------------------------------------------------
// 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,
name: engagementNames.get(id) || '',
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, name: engagementNames.get(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, name) {
super();
this.id = id;
this.cmd = cmd;
this.args = args;
this.target = target || '';
this.name = name || '';
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,
name: this.name,
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]; saveEngagementName(job.runId, job.name); }
}
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;
}
async function startJob(body) {
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
const id = crypto.randomUUID();
const credsPath = await materializeCreds(body, id);
const args = buildArgs({ ...body, creds: credsPath });
const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || '');
jobs.set(id, job);
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
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, ...envOverrides() } });
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 = await 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 });
}
// ---- providers / API keys (in-memory only, never persisted) ----
if (req.method === 'GET' && p === '/api/providers') {
return sendJson(res, 200, PROVIDERS.map(({ key, label, kind, models }) => ({ key, label, kind, models })));
}
if (req.method === 'GET' && p === '/api/keys') {
return sendJson(res, 200, PROVIDERS.map((pr) => ({ provider: pr.key, set: apiKeys.has(pr.key) && !!apiKeys.get(pr.key) })));
}
if (req.method === 'POST' && p === '/api/keys') {
const body = await readBody(req);
if (!body.provider || !PROVIDERS.some((pr) => pr.key === body.provider)) {
return sendJson(res, 400, { error: 'unknown provider' });
}
if (body.key) apiKeys.set(body.provider, body.key);
else apiKeys.delete(body.provider);
return sendJson(res, 200, { ok: true });
}
m = p.match(/^\/api\/keys\/([^/]+)$/);
if (req.method === 'DELETE' && m) {
apiKeys.delete(decodeURIComponent(m[1]));
return sendJson(res, 200, { ok: true });
}
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}`);
});