feat(web): engagement wizard, model/auth picker, Auth & Keys menu, attack-path graph

Full frontend rewrite following a deliberate visual direction (dense
security-operations console — borders over shadows, two radii, one accent,
no gradients/glassmorphism) and fixing real bugs found in review:

- EventSource on the exploit stream never called es.close() on 'done',
  so the browser silently reconnected and re-streamed the whole job
  (duplicate log lines/findings). Fixed.
- Sidebar 'running' step indicator and openRun() matched ANY running run
  instead of the one belonging to the current job (by runId). Fixed.

New:
- 5-step engagement wizard (Asset -> Scope & Auth -> Leads -> Model & Run
  -> Review) replacing the single flat board — inspired by the
  Discovery/Plan/Exploit/Remediate stage model both a.security and
  terra.security use publicly.
- Model is now a real dropdown sourced from /api/providers (mirrors
  harness::models::providers()), with an API-key vs. subscription toggle
  that disables subscription for API-only providers.
- One Auth & Keys menu: target auth header + named roles (IDOR/BOLA/BFLA
  multi-identity testing) materialize into an ephemeral creds.yaml passed
  via --creds; per-provider API keys live in server memory only (never on
  disk) and are merged into every spawned child's env.
- Generative Attack Path Chaining: findings rendered as kill-chain columns
  (recon -> initial-access -> ... -> impact) with chains_from resolved to
  parent titles, live in the run view and static in run detail.
- Findings are now a proper table (severity/title/endpoint/CWE/agent/
  confidence) instead of stacked cards.
- Explicit light/dark theme toggle persisted in localStorage, defaulting
  to light (previously light only won when the OS wasn't in dark mode).
- All UI strings in English.

Backend additions: GET /api/providers, GET/POST/DELETE /api/keys,
ephemeral creds.yaml generation for auth/roles, env override merged into
every exploit-job and REPL child spawn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
CyberSecurityUP
2026-08-23 14:16:46 -03:00
co-authored by Claude Sonnet 5
parent d1d1c71e24
commit bb659412fc
6 changed files with 1172 additions and 473 deletions
+113 -5
View File
@@ -17,6 +17,7 @@
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');
@@ -46,6 +47,90 @@ 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.
@@ -338,14 +423,15 @@ function buildArgs(body) {
return args;
}
function startJob(body) {
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 args = buildArgs(body);
const credsPath = await materializeCreds(body, id);
const args = buildArgs({ ...body, creds: credsPath });
const job = new Job(id, BIN, args, body.repo || body.target || '');
jobs.set(id, job);
const child = spawn(BIN, args, { cwd: ROOT, env: process.env });
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
job.child = child;
let buf = '';
const onData = (chunk) => {
@@ -399,7 +485,7 @@ class ReplSession extends EventEmitter {
function startRepl() {
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
const id = crypto.randomUUID();
const child = spawn(BIN, [], { cwd: ROOT, env: process.env });
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')));
@@ -535,7 +621,7 @@ const server = http.createServer(async (req, res) => {
}
if (req.method === 'POST' && p === '/api/exploit') {
const body = await readBody(req);
const job = startJob(body);
const job = await startJob(body);
return sendJson(res, 200, { id: job.id });
}
m = p.match(/^\/api\/exploit\/([^/]+)$/);
@@ -607,6 +693,28 @@ const server = http.createServer(async (req, res) => {
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 });