Files
NeuroSploit/web/server.js
T
CyberSecurityUP 33fd6ca508 feat(web): add Re-run button to detail view with pre-filled wizard
Adds a "Re-run" button to the run detail view that opens the wizard
pre-populated with the previous run's settings (target, model, votes,
chain depth, recon, max agents, mode flags). Server now includes
launch params in the detail API response by looking up the persisted
job file. Previous findings and reports are preserved (new run ID).
2026-10-18 18:43:02 -03:00

2248 lines
107 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* NeuroSploit v4.3.2 — 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, execFileSync } = require('node:child_process');
const crypto = require('node:crypto');
const { EventEmitter } = require('node:events');
const { StringDecoder } = require('node:string_decoder');
// ---------------------------------------------------------------------------
// 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(() => {});
}
// Per-job persistence: the web server's job state (phase, findings, feed, and
// the non-secret launch params) is mirrored to disk so a server restart or
// crash doesn't lose the run — the UI can list it again and, for an
// interrupted run/whitebox/greybox job, RESUME it (relaunch the REPL, which
// auto-recovers the harness's own on-disk checkpoint and carries findings
// forward). Secrets (API keys, creds-file contents) are never written here.
const CONFIG_DIR = path.join(ROOT, '.neurosploit');
function configFile(name) { return path.join(CONFIG_DIR, `${name}.json`); }
async function ensureConfigDir() { await fsp.mkdir(CONFIG_DIR, { recursive: true }); }
async function readConfig(name) {
try { return JSON.parse(await fsp.readFile(configFile(name), 'utf8')); }
catch { return []; }
}
async function readConfigObj(name) {
try { return JSON.parse(await fsp.readFile(configFile(name), 'utf8')); }
catch { return {}; }
}
async function writeConfig(name, data) {
await ensureConfigDir();
await fsp.writeFile(configFile(name), JSON.stringify(data, null, 2));
}
const JOBS_DIR = path.join(ROOT, '.neurosploit', 'web-jobs');
function jobFile(id) { return path.join(JOBS_DIR, `${id}.json`); }
function persistJob(job) {
if (!job) return;
try {
fs.mkdirSync(JOBS_DIR, { recursive: true });
const snap = job.snapshot();
const rec = {
...snap,
launch: job.launch || null, // sanitized relaunch params (no secrets)
feedTail: job.feed.slice(-300), // enough to repaint the live log
savedAt: Date.now(),
};
fs.writeFileSync(jobFile(job.id), JSON.stringify(rec));
} catch { /* best-effort — persistence never breaks a run */ }
}
function deleteJobFile(id) { try { fs.unlinkSync(jobFile(id)); } catch { /* gone already */ } }
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();
// The console version is whatever the compiled CLI reports — never a hardcoded
// string. This keeps the web UI and the CLI binary in lockstep: bump the crate,
// rebuild, and the title/badge/UA all move together with no web edit. Falls back
// to the package string only when the binary is missing.
const FALLBACK_VERSION = '4.3.1';
function binaryVersion() {
if (!BIN) return FALLBACK_VERSION;
try {
const out = execFileSync(BIN, ['--version'], { encoding: 'utf8', timeout: 5000 });
const m = out.match(/\b(\d+\.\d+\.\d+(?:[-+][\w.]+)?)\b/);
return (m && m[1]) || FALLBACK_VERSION;
} catch { return FALLBACK_VERSION; }
}
const VERSION = binaryVersion();
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-5', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5-1', 'claude-opus-4-8', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'] },
{ key: 'openai', label: 'OpenAI (ChatGPT)', kind: 'cli', envKey: 'OPENAI_API_KEY',
models: ['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', '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.7', 'grok-4.5', 'grok-4', 'grok-4-fast'] },
{ key: 'gemini', label: 'Google Gemini', kind: 'cli', envKey: 'GEMINI_API_KEY',
models: ['gemini-3-pro', 'gemini-3.8-flash', 'gemini-2.5-pro', 'gemini-2.5-flash'] },
{ key: 'opencode', label: 'OpenCode Zen', kind: 'cli', envKey: 'OPENCODE_API_KEY',
models: ['claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-5', 'gpt-6-sol', 'gpt-5.6-sol', 'gpt-5.5', 'gemini-3-pro', 'gemini-3.8-flash', 'grok-4.7', 'grok-4.5', 'glm-5.3', 'deepseek-v4.1', 'deepseek-v4-pro', 'qwen3.8-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: ['qwen3.8-max', 'qwen3.8-omni-flash', 'qwen-max', 'qwen2.5-coder-32b-instruct', 'qwq-plus'] },
// Z.ai GLM: always needs a ZAI_API_KEY (so kind:'api' keeps the key field),
// but with the subscription toggle on it runs agentically through the Claude
// Code CLI pointed at Z.ai's Anthropic-compatible endpoint (devpack/cline).
{ key: 'zai', label: 'Z.ai (GLM)', kind: 'api', envKey: 'ZAI_API_KEY',
models: ['glm-5.3', 'glm-5.2', 'glm-5.1', 'glm-5.3-flashx', 'glm-4.6'] },
{ 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-5-5', 'anthropic/claude-opus-4-8', 'z-ai/glm-5.3', 'qwen/qwen3.8-max', 'deepseek/deepseek-v4.1', '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
// A cheap authenticated endpoint per provider, to confirm a key works. GET a
// models/account list — a 200 means the key authenticates, 401/403 means it does
// not. Providers without a reachable check (local/proxy, or no public endpoint)
// return ok:null — the key is saved, just not auto-verified.
function keyProbe(provider, key) {
const bearer = { Authorization: `Bearer ${key}` };
switch (provider) {
case 'anthropic': return { url: 'https://api.anthropic.com/v1/models', headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' } };
case 'openai': return { url: 'https://api.openai.com/v1/models', headers: bearer };
case 'xai': return { url: 'https://api.x.ai/v1/models', headers: bearer };
case 'gemini': return { url: `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`, headers: {} };
case 'deepseek': return { url: 'https://api.deepseek.com/models', headers: bearer };
case 'mistral': return { url: 'https://api.mistral.ai/v1/models', headers: bearer };
case 'groq': return { url: 'https://api.groq.com/openai/v1/models', headers: bearer };
case 'together': return { url: 'https://api.together.xyz/v1/models', headers: bearer };
case 'openrouter': return { url: 'https://openrouter.ai/api/v1/key', headers: bearer };
case 'moonshot': return { url: 'https://api.moonshot.ai/v1/models', headers: bearer };
case 'qwen': return { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', headers: bearer };
case 'nvidia_nim': return { url: 'https://integrate.api.nvidia.com/v1/models', headers: bearer };
case 'zai': return { url: 'https://api.z.ai/api/paas/v4/models', headers: bearer };
default: return null; // opencode, nous, litellm, azure, ollama, llamacpp — not auto-verifiable here
}
}
async function verifyKey(provider, key) {
const probe = keyProbe(provider, key);
if (!probe) return { ok: null, message: 'saved — this provider is not auto-verifiable here' };
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 10000);
const r = await fetch(probe.url, { headers: probe.headers, signal: ctrl.signal }).finally(() => clearTimeout(timer));
if (r.ok) return { ok: true, message: 'key is valid' };
if (r.status === 401 || r.status === 403) return { ok: false, message: `key rejected (HTTP ${r.status})` };
if (r.status === 429) return { ok: true, message: 'key valid (rate-limited)' };
return { ok: null, message: `saved — provider returned HTTP ${r.status}` };
} catch (e) {
return { ok: null, message: `saved — could not reach provider (${e.name === 'AbortError' ? 'timeout' : e.message})` };
}
}
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;
}
async function cloudEnvOverrides(cloudProvider) {
if (!cloudProvider) return {};
const integs = await readConfig('integrations');
const cfg = (integs.find(i => i.id === cloudProvider) || {}).config || {};
const env = {};
if (cloudProvider === 'aws') {
if (cfg.accesskey) env.AWS_ACCESS_KEY_ID = cfg.accesskey;
if (cfg.secretkey) env.AWS_SECRET_ACCESS_KEY = cfg.secretkey;
if (cfg.region) env.AWS_DEFAULT_REGION = cfg.region;
if (cfg.profile) env.AWS_PROFILE = cfg.profile;
if (cfg.rolearn) env.AWS_ROLE_ARN = cfg.rolearn;
} else if (cloudProvider === 'gcp') {
if (cfg.serviceaccount) env.GOOGLE_APPLICATION_CREDENTIALS = cfg.serviceaccount;
if (cfg.project) env.GCP_PROJECT = cfg.project;
if (cfg.region) env.CLOUDSDK_COMPUTE_REGION = cfg.region;
} else if (cloudProvider === 'azure') {
if (cfg.tenantid) env.AZURE_TENANT_ID = cfg.tenantid;
if (cfg.clientid) env.AZURE_CLIENT_ID = cfg.clientid;
if (cfg.clientsecret) env.AZURE_CLIENT_SECRET = cfg.clientsecret;
if (cfg.subscriptionid) env.AZURE_SUBSCRIPTION_ID = cfg.subscriptionid;
}
return env;
}
async function validateIntegration(id, cfg) {
const https = require('https');
const http = require('http');
const fetch = (url, opts = {}) => new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http;
const req = mod.request(url, { method: 'GET', timeout: 8000, ...opts }, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => resolve({ status: res.statusCode, body }));
});
req.on('error', e => reject(e));
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end(opts.body || undefined);
});
try {
if (id === 'github') {
const token = cfg.token || cfg.pat || '';
if (!token) return { ok: false, message: 'Token is required' };
const r = await fetch('https://api.github.com/user', { headers: { Authorization: `token ${token}`, 'User-Agent': 'NeuroSploit', Accept: 'application/json' } });
if (r.status === 200) { const u = JSON.parse(r.body); return { ok: true, message: `Authenticated as ${u.login}` }; }
if (r.status === 401) return { ok: false, message: 'Invalid token — 401 Unauthorized' };
return { ok: false, message: `GitHub returned ${r.status}` };
}
if (id === 'gitlab') {
const token = cfg.token || cfg.pat || '';
const url = cfg.url || 'https://gitlab.com';
if (!token) return { ok: false, message: 'Token is required' };
const r = await fetch(`${url.replace(/\/$/, '')}/api/v4/user`, { headers: { 'PRIVATE-TOKEN': token, Accept: 'application/json' } });
if (r.status === 200) { const u = JSON.parse(r.body); return { ok: true, message: `Authenticated as ${u.username}` }; }
if (r.status === 401) return { ok: false, message: 'Invalid token — 401 Unauthorized' };
return { ok: false, message: `GitLab returned ${r.status}` };
}
if (id === 'jira') {
const domain = cfg.domain || cfg.url || '';
const email = cfg.email || '';
const token = cfg.token || cfg.apitoken || '';
if (!domain || !email || !token) return { ok: false, message: 'Domain, email and API token are required' };
const base = domain.startsWith('http') ? domain.replace(/\/$/, '') : `https://${domain}`;
const auth = Buffer.from(`${email}:${token}`).toString('base64');
const r = await fetch(`${base}/rest/api/3/myself`, { headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' } });
if (r.status === 200) { const u = JSON.parse(r.body); return { ok: true, message: `Authenticated as ${u.displayName || u.emailAddress}` }; }
if (r.status === 401) return { ok: false, message: 'Invalid credentials — 401 Unauthorized' };
return { ok: false, message: `Jira returned ${r.status}` };
}
if (id === 'slack') {
const token = cfg.token || cfg.bottoken || '';
if (!token) return { ok: false, message: 'Bot token is required' };
const r = await fetch('https://slack.com/api/auth.test', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' } });
if (r.status === 200) {
const j = JSON.parse(r.body);
if (j.ok) return { ok: true, message: `Connected to ${j.team} as ${j.user}` };
return { ok: false, message: `Slack: ${j.error}` };
}
return { ok: false, message: `Slack returned ${r.status}` };
}
if (id === 'aws') {
const key = cfg.accesskey || '';
const secret = cfg.secretkey || '';
if (!key && !cfg.profile) return { ok: false, message: 'Access Key or Profile is required' };
if (key && !secret) return { ok: false, message: 'Secret Access Key is required when Access Key is set' };
const { execSync } = require('child_process');
const env = { ...process.env };
if (key) { env.AWS_ACCESS_KEY_ID = key; env.AWS_SECRET_ACCESS_KEY = secret; }
if (cfg.region) env.AWS_DEFAULT_REGION = cfg.region;
if (cfg.profile) env.AWS_PROFILE = cfg.profile;
try {
const out = execSync('aws sts get-caller-identity --output json 2>&1', { env, timeout: 10000, encoding: 'utf8' });
const j = JSON.parse(out);
return { ok: true, message: `Account ${j.Account} — ${j.Arn}` };
} catch (e) {
const raw = (e.stdout || e.stderr || e.message || '').trim();
const msg = raw.split('\n').pop();
return { ok: false, message: msg || 'AWS CLI validation failed' };
}
}
if (id === 'gcp') {
const sa = cfg.serviceaccount || '';
const project = cfg.project || '';
if (!sa && !project) return { ok: false, message: 'Service Account key path or Project ID is required' };
if (sa) {
const fs = require('fs');
if (!fs.existsSync(sa)) return { ok: false, message: `Service Account key file not found: ${sa}` };
try { const j = JSON.parse(fs.readFileSync(sa, 'utf8')); return { ok: true, message: `SA: ${j.client_email || 'valid JSON key'}` }; }
catch { return { ok: false, message: 'Service Account key file is not valid JSON' }; }
}
return { ok: true, message: `Project: ${project} (credentials not verified)` };
}
if (id === 'azure') {
const tenant = cfg.tenantid || '';
const client = cfg.clientid || '';
const secret = cfg.clientsecret || '';
if (!tenant || !client || !secret) return { ok: false, message: 'Tenant ID, Client ID and Client Secret are all required' };
const body = `grant_type=client_credentials&client_id=${encodeURIComponent(client)}&client_secret=${encodeURIComponent(secret)}&scope=https%3A%2F%2Fmanagement.azure.com%2F.default`;
const r = await fetch(`https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
if (r.status === 200) { return { ok: true, message: 'Token acquired — credentials valid' }; }
try { const j = JSON.parse(r.body); return { ok: false, message: j.error_description?.split('\r\n')[0] || j.error || `Azure returned ${r.status}` }; }
catch { return { ok: false, message: `Azure returned ${r.status}` }; }
}
if (id === 'mcp') {
return { ok: true, message: 'MCP config saved' };
}
return { ok: true, message: 'Saved (no validation available)' };
} catch (e) {
return { ok: false, message: e.message || 'Validation failed' };
}
}
/// 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';
}
// Turn the web form's Scoping/Guardrails object into the scope YAML the CLI
// loads with --scope-file. The hard list is the boundary; everything else is a
// guardrail inside it. Written to a temp file per job.
function buildScopeYaml(scope) {
const lines = [];
const list = (v) => (Array.isArray(v) ? v : String(v || '').split(/[\n,;]+/)).map((x) => String(x).trim()).filter(Boolean);
const block = (key, items) => {
if (!items.length) return;
lines.push(`${key}:`);
for (const it of items) lines.push(` - ${JSON.stringify(it)}`);
};
block('hard', list(scope.hard));
block('exclude', list(scope.exclude));
const soft = [];
const obs = list(scope.observeOnly);
if (obs.length) { soft.push(' observe_only:'); for (const o of obs) soft.push(` - ${JSON.stringify(o)}`); }
soft.push(` allow_destructive_methods: ${scope.allowDestructive ? 'true' : 'false'}`);
soft.push(` allow_account_creation: ${scope.allowAccountCreation === false ? 'false' : 'true'}`);
if (scope.maxAccounts !== undefined && scope.maxAccounts !== '') soft.push(` max_accounts: ${Number(scope.maxAccounts) || 0}`);
if (scope.rateLimit !== undefined && scope.rateLimit !== '') soft.push(` max_requests_per_minute: ${Number(scope.rateLimit) || 0}`);
const forb = list(scope.forbidden);
if (forb.length) { soft.push(' forbidden_payloads:'); for (const fp of forb) soft.push(` - ${JSON.stringify(fp)}`); }
const notes = list(scope.notes);
if (notes.length) { soft.push(' notes:'); for (const n of notes) soft.push(` - ${JSON.stringify(n)}`); }
lines.push('soft:');
lines.push(...soft);
return lines.join('\n') + '\n';
}
// Only materialize a scope file when the operator actually set a hard boundary
// through the form — otherwise the run keeps its normal target+flags behaviour.
async function materializeScope(body, jobId) {
const scope = body.scope;
if (!scope) return undefined;
const hard = (Array.isArray(scope.hard) ? scope.hard : String(scope.hard || '').split(/[\n,;]+/)).map((x) => String(x).trim()).filter(Boolean);
if (!hard.length) return undefined; // no boundary set — nothing to enforce beyond flags
const dir = path.join(os.tmpdir(), 'neurosploit-web');
await fsp.mkdir(dir, { recursive: true });
const file = path.join(dir, `${jobId}.scope.yaml`);
await fsp.writeFile(file, buildScopeYaml(scope));
return file;
}
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;
}
// An imported API collection (Postman/OpenAPI/HAR). The UI either uploads the
// file content (collectionData) or points at a path already on disk
// (collectionPath). Uploaded content is written to a temp file so the CLI's
// --collection flag can read it.
async function materializeCollection(body, jobId) {
if (body.collectionPath) return body.collectionPath; // path on disk wins
if (!body.collectionData) return undefined;
const dir = path.join(os.tmpdir(), 'neurosploit-web');
await fsp.mkdir(dir, { recursive: true });
const file = path.join(dir, `${jobId}.collection.json`);
await fsp.writeFile(file, body.collectionData);
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 (name.startsWith('custom_')) return 'Custom Leads'; // generated via /api/leads/generate
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 = [
'Custom Leads',
'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;
}
// ---------------------------------------------------------------------------
// Custom leads — "+ Custom lead" generates a REAL specialist-agent markdown
// file (same format agents_md/vulns/*.md uses) via the `claude` CLI on the
// operator's Anthropic subscription, so a custom lead is an actual pinnable
// agent, not just free text folded into --focus. Mirrors the exact one-shot
// invocation harness::models::cli_login_status() uses for the same CLI.
// ---------------------------------------------------------------------------
const GEN_MODEL = 'claude-opus-4-8'; // matches Session::default() in app/src/repl.rs
const GEN_TIMEOUT_MS = 90_000;
function slugify(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'lead';
}
function buildSkillGenPrompt(description) {
return `Write ONE new security-testing specialist-agent file for this custom lead, in EXACTLY this markdown shape and nothing else — no code fences around the whole thing, no preamble, no explanation, just the file content starting at the first line:
# <Short Title> Agent
## User Prompt
You are testing **{target}** for <the specific vulnerability class this lead targets>.
**Recon Context:**
{recon_json}
**METHODOLOGY:**
### 1. <step name>
- <concrete technique>
### 2. <step name>
- <concrete technique>
(as many numbered steps as the vuln class actually needs — terse, technical, no filler)
### Report
\`\`\`
FINDING:
- Title: ...
- Severity: ...
- CWE: CWE-<pick the single most fitting CWE number>
- Endpoint: [URL]
- Evidence: ...
- Impact: ...
- Remediation: ...
\`\`\`
## System Prompt
<one paragraph: the agent's persona plus the ONE calibration rule that stops it from reporting a finding without real proof>
The operator's custom lead request, verbatim: "${description}"
Match the doctrine style of NeuroSploit's other agents_md/vulns/*.md files: terse, technical, no marketing language, one CWE, a real report template.
Do not use any tools (no file writes, no bash, no search) — this is a pure text-completion task. Respond with ONLY the markdown file content above, nothing before it and nothing after it.`;
}
function generateCustomLead(description) {
return new Promise((resolve, reject) => {
if (!binaryOnPath('claude')) {
return reject(new Error("claude CLI not found on PATH — install Claude Code and run `claude` to log in first"));
}
// No --dangerously-skip-permissions here: this is a pure text-completion
// call (no bash/file tools needed), and granting tool access made claude
// try to write the file itself and narrate doing so instead of just
// returning text — see buildSkillGenPrompt()'s explicit "no tools" line.
const child = spawn('claude', ['-p', '--model', GEN_MODEL, '--output-format', 'text'], { env: process.env });
let out = '', err = '';
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('generation timed out')); }, GEN_TIMEOUT_MS);
child.stdout.on('data', (c) => { out += c; });
child.stderr.on('data', (c) => { err += c; });
child.on('error', (e) => { clearTimeout(timer); reject(new Error(`claude CLI failed to start: ${e.message}`)); });
child.on('close', (code) => {
clearTimeout(timer);
if (!out.trim()) return reject(new Error(err.trim() || `claude exited ${code} with no output — is it logged in? run \`claude\` once to check.`));
resolve(out);
});
child.stdin.write(buildSkillGenPrompt(description));
child.stdin.end();
});
}
function binaryOnPath(bin) {
const dirs = (process.env.PATH || '').split(path.delimiter);
return dirs.some((d) => { try { return fs.existsSync(path.join(d, bin)); } catch { return false; } });
}
// ---------------------------------------------------------------------------
// 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 [];
}
// Which run ids are actually driven by a LIVE job right now. A run whose
// status.json still says "running" but has no live job is stale (the process
// died / the server restarted) — show it as interrupted, not running, so the
// sidebar's Running group reflects reality.
const liveRunIds = new Set(
[...jobs.values()].filter((j) => !j.done && j.runId).map((j) => j.runId)
);
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;
let state = status.state || 'unknown';
if (state === 'running' && !liveRunIds.has(id)) state = 'interrupted';
return {
id,
ts,
name: engagementNames.get(id) || '',
target: status.target || meta.target || id.replace(/^ns-\d+-/, ''),
state,
findings: findings.length,
severities: sevCount,
hasReport: fs.existsSync(path.join(dir, 'report.html')) || fs.existsSync(path.join(dir, 'report.pdf')),
};
}));
// A brand-new live job hasn't written its run dir yet (the run id is only
// known after recon prints it). Surface those in-memory running jobs so the
// engagement shows in the sidebar the moment it starts, not minutes later.
const onDisk = new Set(ids);
for (const j of jobs.values()) {
if (j.done) continue;
if (j.runId && onDisk.has(j.runId)) continue; // already covered above
const sevCount = {};
for (const f of j.findings || []) sevCount[f.severity] = (sevCount[f.severity] || 0) + 1;
runs.push({
id: j.runId || j.id,
jobId: j.id,
ts: Math.floor((j.startedAt || Date.now()) / 1000),
name: j.name || '',
target: j.target || '',
state: 'running',
findings: (j.findings || []).length,
severities: sevCount,
hasReport: false,
});
}
runs.sort((a, b) => b.ts - a.ts);
return runs;
}
/// Flat aggregate over every run for the dashboard.
///
/// Returns per-finding tuples rather than a computed risk number: the FAIR
/// estimate depends on assumptions (contact frequency, loss magnitude per
/// severity) that belong to the operator, not to this server, so the browser
/// computes it from parameters the operator can see and change.
async function stats() {
let ids = [];
try {
ids = (await fsp.readdir(RUNS_DIR)).filter((d) => d.startsWith('ns-'));
} catch {
return { runs: [], findings: [], generated: Date.now() };
}
const runs = [];
const findings = [];
await Promise.all(ids.map(async (id) => {
const dir = path.join(RUNS_DIR, id);
const [status, fs_] = await Promise.all([
readJsonSafe(path.join(dir, 'status.json'), {}),
readJsonSafe(path.join(dir, 'findings.json'), []),
]);
const meta = await readJsonSafe(path.join(dir, 'meta.json'), {});
const tsMatch = id.match(/^ns-(\d+)-/);
const ts = status.ts || (tsMatch ? Number(tsMatch[1]) : 0);
const target = status.target || meta.target || id.replace(/^ns-\d+-/, '');
runs.push({
id,
ts,
name: engagementNames.get(id) || '',
target,
state: status.state || 'unknown',
agentsRan: status.agents_ran || 0,
findings: fs_.length,
});
for (const f of fs_) {
findings.push({
runId: id,
target,
ts,
severity: f.severity || 'Info',
cwe: f.cwe || '',
owasp: f.owasp || '',
stage: f.stage || '',
agent: f.agent || '',
title: f.title || '',
exploitability: f.exploitability || '',
confidence: typeof f.confidence === 'number' ? f.confidence : 0,
reviewStatus: f.review_status || '',
});
}
}));
runs.sort((a, b) => b.ts - a.ts);
return { runs, findings, generated: Date.now() };
}
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', 'audit.jsonl', 'graph.json']
.filter((f) => fs.existsSync(path.join(dir, f)));
const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []);
// What the harness produced beyond the report: the attack graph, the shared
// traces, the recorded traffic, the deliverables. Presence only — the tabs
// fetch the content on demand.
const exists = (f) => fs.existsSync(path.join(dir, f));
const traces = await fsp.readdir(path.join(dir, 'traces')).catch(() => []);
const extras = {
graph: exists('graph/chains.json') || exists('graph/live-chains.json') || exists('graph/graph.json') || exists('graph/live.json'),
todo: exists('graph/todo.json'),
traces: traces.filter((f) => f.endsWith('.md')).length,
flows: exists('flows.jsonl'),
deliverables: await listTree(path.join(dir, 'deliverables'), 'deliverables'),
};
if (status.state === 'running') {
const liveRunIds = new Set([...jobs.values()].filter((j) => !j.done && j.runId).map((j) => j.runId));
if (!liveRunIds.has(id)) status.state = 'interrupted';
}
let launch = null;
const liveJob = [...jobs.values()].find((j) => j.runId === id);
if (liveJob?.launch) launch = liveJob.launch;
else {
try {
const jfiles = await fsp.readdir(JOBS_DIR);
for (const jf of jfiles) {
const rec = JSON.parse(await fsp.readFile(path.join(JOBS_DIR, jf), 'utf8'));
if (rec.runId === id && rec.launch) { launch = rec.launch; break; }
}
} catch { /* no job files */ }
}
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs, extras, launch };
}
/// Relative file list under a directory (one level of nesting is enough for
/// `deliverables/`: tests/, nuclei/, detection/).
async function listTree(abs, rel, depth = 0) {
const out = [];
const ents = await fsp.readdir(abs, { withFileTypes: true }).catch(() => []);
for (const e of ents) {
if (e.isDirectory() && depth < 2) out.push(...await listTree(path.join(abs, e.name), `${rel}/${e.name}`, depth + 1));
else if (e.isFile()) out.push(`${rel}/${e.name}`);
}
return out.sort();
}
/// Run the real CLI against a run directory and capture its output. The web
/// console never re-implements harness logic — it shells out, like `rebuild`.
function runCli(args) {
return new Promise((resolve) => {
if (!BIN) return resolve({ code: -1, buf: 'neurosploit binary not found — run `cargo build --release` in neurosploit-rs/' });
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
let buf = '';
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
child.stderr.on('data', (c) => { buf += c.toString('utf8'); });
child.on('close', (code) => resolve({ code, buf: stripAnsi(buf) }));
child.on('error', (e) => resolve({ code: -1, buf: e.message }));
});
}
/// The attack graph a run produced: chains, the dependency todolist and the
/// graph itself. Live files (written during the run) win while it runs.
async function runGraph(dir) {
const pick = async (...names) => {
for (const n of names) {
const v = await readJsonSafe(path.join(dir, n), null);
if (v) return v;
}
return null;
};
return {
chains: await pick('graph/live-chains.json', 'graph/chains.json'),
todo: await pick('graph/todo.json'),
graph: await pick('graph/live.json', 'graph/graph.json'),
};
}
/// The recording interceptor's log, filtered and paged.
async function runTraffic(dir, q) {
const text = await fsp.readFile(path.join(dir, 'flows.jsonl'), 'utf8').catch(() => '');
let rows = text.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
const hostOf = (u) => { try { return new URL(u).host; } catch { return String(u || '').split('/')[0]; } };
const hosts = [...new Set(rows.map((r) => hostOf(r.url)))].sort();
const host = (q.get('host') || '').trim().toLowerCase();
const method = (q.get('method') || '').trim().toUpperCase();
const needle = (q.get('q') || '').trim().toLowerCase();
if (host) rows = rows.filter((r) => hostOf(r.url).toLowerCase().includes(host));
if (method) rows = rows.filter((r) => (r.method || '').toUpperCase() === method);
if (needle) rows = rows.filter((r) => `${r.method} ${r.url} ${r.status} ${r.content_type}`.toLowerCase().includes(needle));
rows.reverse(); // newest first
const offset = Math.max(0, parseInt(q.get('offset') || '0', 10) || 0);
const limit = Math.min(500, Math.max(1, parseInt(q.get('limit') || '100', 10) || 100));
return { total: rows.length, hosts, rows: rows.slice(offset, offset + limit), offset, limit };
}
/// Search every agent's trace (the shared-trace store) for a keyword.
async function searchTraces(dir, needle) {
const tdir = path.join(dir, 'traces');
const files = (await fsp.readdir(tdir).catch(() => [])).filter((f) => f.endsWith('.md')).sort();
const list = [];
const hits = [];
const n = (needle || '').trim().toLowerCase();
for (const f of files) {
const text = await fsp.readFile(path.join(tdir, f), 'utf8').catch(() => '');
list.push({ agent: f.replace(/\.md$/, ''), file: `traces/${f}`, bytes: Buffer.byteLength(text) });
if (!n || hits.length >= 200) continue;
const lines = text.split('\n');
lines.forEach((line, i) => {
if (hits.length < 200 && line.toLowerCase().includes(n)) {
hits.push({ agent: f.replace(/\.md$/, ''), file: `traces/${f}`, line: i + 1, snippet: stripAnsi(line).slice(0, 300) });
}
});
}
return { traces: list, hits, query: needle || '' };
}
/// The CLI prints progress before the `--json` payload; take the last line that
/// parses as JSON so a banner or a warning never breaks the parse.
function lastJsonLine(buf) {
const lines = stripAnsi(buf || '').split('\n').map((l) => l.trim()).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].startsWith('[') || lines[i].startsWith('{')) return lines[i];
}
return '[]';
}
/// A finding-id is used as a file name; keep it to the safe charset.
function safeId(id) { return /^[a-zA-Z0-9_.:-]+$/.test(id) ? id : null; }
async function readRetests(dir, findingId) {
const id = safeId(findingId);
if (!id) return [];
return await readJsonSafe(path.join(dir, 'retests', `${id}.json`), []);
}
/// Append one re-test outcome to the finding's dated history (newest last).
async function appendRetest(dir, findingId, entry) {
const id = safeId(findingId);
if (!id) return [];
const rdir = path.join(dir, 'retests');
await fsp.mkdir(rdir, { recursive: true }).catch(() => {});
const file = path.join(rdir, `${id}.json`);
const history = await readJsonSafe(file, []);
history.push(entry);
await fsp.writeFile(file, JSON.stringify(history, null, 2)).catch(() => {});
return history;
}
/// The real HTTP exchanges that back one finding: first the ones the harness
/// measured itself (the finding's own evidence_data — baseline/attack, the
/// identity pair, matrix probes, the oracle), each with its wire provenance;
/// then, when the recording interceptor ran, the recorded flows on the same
/// endpoint. This is the finding's proof as bytes, not as the agent's prose.
async function findingTraffic(dir, findingId) {
const findings = await readJsonSafe(path.join(dir, 'findings.json'), []);
const f = findings.find((x) => x.id === findingId);
if (!f) return { error: 'finding not found', exchanges: [], flows: [] };
const ev = f.evidence_data || {};
const exchanges = [];
const push = (role, ex) => { if (ex && (ex.url || ex.body)) exchanges.push({ role, ...ex }); };
push('baseline', ev.baseline);
push('attack', ev.attack);
(ev.repeats || []).forEach((ex, i) => push(`repeat ${i + 1}`, ex));
push('as owner', ev.identity_a);
push('as other identity', ev.identity_b);
(ev.matrix || []).forEach((p) => push(`matrix · ${p.identity || '?'} (expect ${p.expect || '?'})`, p.exchange));
if (ev.oracle) { push('oracle · should-be-denied', ev.oracle.denied); push('oracle · control', ev.oracle.control); }
// Recorded flows whose path matches the finding's endpoint, newest first.
let ep = '';
try { ep = new URL(f.endpoint).pathname; } catch { ep = String(f.endpoint || '').replace(/^https?:\/\/[^/]+/, '') || f.endpoint; }
const text = await fsp.readFile(path.join(dir, 'flows.jsonl'), 'utf8').catch(() => '');
const flows = text.split('\n').filter(Boolean)
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter((r) => r && ep && String(r.url || '').includes(ep))
.reverse().slice(0, 50);
return { finding: { id: f.id, title: f.title, endpoint: f.endpoint }, wire: ev.wire || null, exchanges, flows };
}
/// A stable identity for a finding across runs of the same target: its CWE (or,
/// lacking one, a slug of its title) plus the endpoint's path. Query strings and
/// object ids vary run to run, so they are dropped — the point is "the same bug",
/// not "the same request".
function findingKey(f) {
const cwe = String(f.cwe || '').trim().toUpperCase();
const base = cwe || String(f.title || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48);
let p = '';
try { p = new URL(f.endpoint).pathname; } catch { p = String(f.endpoint || '').replace(/^https?:\/\/[^/]+/, '').split('?')[0]; }
p = p.replace(/\/\d+(?=\/|$)/g, '/{id}').replace(/\/+$/, '') || '/';
return `${base}|${p}`;
}
const isReview = (f) => f.review_status === 'needs-review' || f.review_status === 'rejected';
/// Compare one finished run to the most recent EARLIER run of the same target:
/// which findings are new, which recur, and which were confirmed before and are
/// now gone. Runs over whatever is already on disk — no model, no network.
async function runRegression(dir, id) {
const norm = (t) => String(t || '').replace(/\/+$/, '').toLowerCase();
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 target = norm(status.target || meta.target);
const ts = (id.match(/^ns-(\d+)-/) || [])[1] | 0;
if (!target) return { prev: null, status: {}, fixed: [] };
// The newest earlier run of the same target.
let ids = [];
try { ids = (await fsp.readdir(RUNS_DIR)).filter((d) => d.startsWith('ns-') && d !== id); } catch { /* none */ }
let prev = null;
for (const other of ids) {
const ots = (other.match(/^ns-(\d+)-/) || [])[1] | 0;
if (!ots || ots >= ts) continue;
const [om, os] = await Promise.all([
readJsonSafe(path.join(RUNS_DIR, other, 'meta.json'), {}),
readJsonSafe(path.join(RUNS_DIR, other, 'status.json'), {}),
]);
if (norm(os.target || om.target) !== target) continue;
if (!prev || ots > prev.ts) prev = { id: other, ts: ots };
}
if (!prev) return { prev: null, status: {}, fixed: [] };
const prevFindings = await readJsonSafe(path.join(RUNS_DIR, prev.id, 'findings.json'), []);
const prevByKey = new Map();
for (const pf of prevFindings) {
const k = findingKey(pf);
if (!prevByKey.has(k)) prevByKey.set(k, pf);
}
const nowKeys = new Set(findings.map(findingKey));
// Per-finding verdict for the current run: recurring if the prior run had it.
const perFinding = {};
for (const f of findings) perFinding[f.id] = prevByKey.has(findingKey(f)) ? 'recurring' : 'new';
// Fixed: confirmed in the prior run, absent now.
const fixed = prevFindings
.filter((pf) => !isReview(pf) && !nowKeys.has(findingKey(pf)))
.map((pf) => ({ title: pf.title, severity: pf.severity, cwe: pf.cwe, endpoint: pf.endpoint }));
return { prev: { id: prev.id, ts: prev.ts, when: new Date(prev.ts * 1000).toISOString() }, status: perFinding, fixed };
}
function safeRunDir(id) {
if (!/^[a-zA-Z0-9_.-]+$/.test(id)) return null;
const dir = path.join(RUNS_DIR, id);
// Contain the delete to RUNS_DIR: reject anything that resolves out of it
// (defence in depth on top of the charset check, which already forbids `/`).
if (dir !== RUNS_DIR && !dir.startsWith(RUNS_DIR + path.sep)) return null;
return dir;
}
/// Permanently delete one run: its whole directory (findings, evidence, PoCs,
/// every report artifact) and its remembered engagement name. Returns false if
/// the id is unsafe or the directory does not exist.
async function deleteRun(id) {
const dir = safeRunDir(id);
if (!dir || dir === RUNS_DIR || !fs.existsSync(dir)) return false;
await fsp.rm(dir, { recursive: true, force: true });
if (engagementNames.delete(id)) {
await fsp.mkdir(path.dirname(NAMES_FILE), { recursive: true })
.then(() => fsp.writeFile(NAMES_FILE, JSON.stringify(Object.fromEntries(engagementNames), null, 2)))
.catch(() => {});
}
return true;
}
/// Delete every run under RUNS_DIR (ns-* directories only). Returns the count.
async function deleteAllRuns() {
let ids = [];
try { ids = (await fsp.readdir(RUNS_DIR)).filter((d) => d.startsWith('ns-')); } catch { return 0; }
let n = 0;
for (const id of ids) { if (await deleteRun(id)) n += 1; }
return n;
}
// ---------------------------------------------------------------------------
// 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.pinnedAgents = [];
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);
// Persist on state-moving events (a finding, a phase marker, completion),
// throttled so a chatty log stream doesn't hammer the disk.
if (evt.type === 'finding' || evt.type === 'done') this._persistSoon(0);
else this._persistSoon(1500);
}
_persistSoon(delay) {
if (this._persistTimer) return;
this._persistTimer = setTimeout(() => { this._persistTimer = null; persistJob(this); }, delay);
if (this._persistTimer.unref) this._persistTimer.unref();
}
snapshot() {
return {
id: this.id,
target: this.target,
name: this.name,
pinnedAgents: this.pinnedAgents,
interactive: !!this.repl,
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,
interrupted: !!this.interrupted,
resumable: !!this.resumable,
agentStatus: this.agentStatus || {},
};
}
}
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 });
// The REPL/CLI decorates every streamed line with a leading glyph + spaces
// (" · ", " ✗ ", " 🧪 testing ", …). Machine markers (finding_json:,
// "exploit <name> via … → N candidate(s)") therefore don't start at column 0,
// so strip the decoration before matching — otherwise live findings and the
// per-agent progress never reach the UI even though the run is producing them.
const norm = line.replace(/^\s*[^\w/{(]*\s*/u, '').replace(/^testing\s+/i, '');
const lown = norm.toLowerCase();
if (low.includes('paused by operator')) job.phase = 'paused (operator)';
else if (low.includes('resumed by operator') || low.includes('▶ resumed')) job.phase = 'running';
else 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 (lown.startsWith('recon') || lown.startsWith('ai-recon') || low.includes('recon round') || lown.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 (lown.startsWith('exploit') || lown.startsWith('test ') || low.includes('launching agent')) job.phase = 'exploiting';
else if (lown.startsWith('vote') || low.includes('validating')) job.phase = 'validating';
else if (lown.startsWith('chain')) job.phase = 'chaining';
else if (low.includes('phase complete') || low.includes('validated finding(s)')) {
job.phase = 'complete';
// A REPL-backed job's child process doesn't exit when the engagement
// finishes (the session stays open for /report, /continue, another
// /run, ...) — so 'done' has to come from content, not process exit.
if (job.repl && !job.done) { job.done = true; job.push({ type: 'done', exitCode: 0 }); }
}
if (/candidate\(s\)/.test(lown) && /^(exploit|test|analyze|review)\b/.test(lown)) job.agentsDone += 1;
const fj = norm.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); }
// --- live per-agent status, for the "what's being tested" panel ---
if (!job.agentStatus) job.agentStatus = {};
// Roster: "selected N ... agent(s): a, b, c"
const roster = line.match(/selected\s+\d+[^:]*agent\(s\):\s*(.+)$/i);
if (roster) {
for (const nm of roster[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean)) {
if (!job.agentStatus[nm]) job.agentStatus[nm] = { status: 'pending', found: 0 };
}
}
// Start: "▶ launching agent: <name> (<title>)"
const launch = line.match(/launching agent:\s*([^\s(]+)/);
if (launch) {
const nm = launch[1];
job.agentStatus[nm] = { ...(job.agentStatus[nm] || {}), status: 'running', found: job.agentStatus[nm]?.found || 0, title: (line.match(/\(([^)]+)\)\s*$/) || [])[1] || '' };
job.push({ type: 'agent', name: nm, status: 'running' });
}
// Done: "exploit|analyze|test <name> via <model> → N candidate(s)"
const done = norm.match(/^(?:exploit|analyze|test|review)\s+(\S+)\s+via\s+.+?(?:→|->)\s*(\d+)\s+candidate/i);
if (done) {
const nm = done[1]; const n = Number(done[2]) || 0;
job.agentStatus[nm] = { ...(job.agentStatus[nm] || {}), status: 'done', found: n };
job.push({ type: 'agent', name: nm, status: 'done', found: n });
}
// Failed: "<verb> <name> failed: ..."
const failed = norm.match(/^(?:exploit|analyze|test|chain|review)\s+(\S+)\s+failed/i);
if (failed) {
const nm = failed[1];
job.agentStatus[nm] = { ...(job.agentStatus[nm] || {}), status: 'failed' };
job.push({ type: 'agent', name: nm, status: 'failed' });
}
}
function buildArgs(body) {
const rawMode = body.mode || 'run';
const mode = rawMode === 'cloud' ? 'host' : rawMode;
const args = [mode];
if (rawMode === 'cloud') {
args.push(body.target || 'localhost');
args.push('--cloud', body.target || 'aws');
} else 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));
const hasQuick = ['run', 'whitebox', 'greybox'].includes(mode);
if (body.quick && hasQuick) args.push('--quick');
if (body.research && (mode === 'whitebox' || mode === 'greybox')) args.push('--research');
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.collectionPath) args.push('--collection', body.collectionPath);
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);
// Budget: omitted entirely means the full run, exactly as before budgets
// existed — the web console never caps a run the operator didn't cap.
if (body.budget && body.budget !== 'unlimited') args.push('--budget', body.budget);
if (body.tokenLimit) args.push('--token-limit', String(body.tokenLimit));
if (body.deepTestLimit) args.push('--deep-test-limit', String(body.deepTestLimit));
if (body.order === 'depth-first') args.push('--depth-first');
else if (body.order === 'coverage-first') args.push('--coverage-first');
if (body.samplePerRoute) args.push('--sample-per-route', String(body.samplePerRoute));
if (body.transport && body.transport !== 'direct') args.push('--transport', body.transport);
if (body.oobDomain) args.push('--oob-domain', body.oobDomain);
if (body.oobHttp) args.push('--oob-http', body.oobHttp);
if (body.oobDns) args.push('--oob-dns', body.oobDns);
if (body.sms) args.push('--sms', body.sms);
if (body.typesafe) args.push('--typesafe', body.typesafe);
if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept);
if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox);
if (body.revalidatePoc) args.push('--revalidate-poc');
for (const fw of body.compliance || []) args.push('--compliance', fw);
// Authorization: the signed grant caps the scope, the extra in-scope entries
// can only narrow within it, and the environment scales every risk score.
for (const entry of body.inScope || []) args.push('--in-scope', entry);
if (body.scopePath) args.push('--scope-file', body.scopePath);
if (body.capability) args.push('--capability-token', body.capability);
if (body.environment) args.push('--environment', body.environment);
if (body.policyProfile) args.push('--policy', body.policyProfile);
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 scopePath = await materializeScope(body, id);
const collectionPath = await materializeCollection(body, id);
const args = buildArgs({ ...body, creds: credsPath, scopePath, collectionPath });
const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || '');
job.pinnedAgents = body.agents || [];
jobs.set(id, job);
const cloudEnv = body.cloudProvider ? await cloudEnvOverrides(body.cloudProvider) : {};
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides(), ...cloudEnv } });
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;
}
/// Turn the wizard's config into the REPL commands that produce the same
/// engagement (`/target`/`/repo` → `/model` → toggles → `/only` → `/run`).
/// `/only` is what makes this equivalent to the CLI's `--only` — REPL had no
/// such command before this feature (added to app/src/repl.rs alongside it).
/// Flags that apply to every mode, including the REPL-backed one. The REPL
/// takes them as argv because a `/`-command for an authorization ceiling would
/// let the session widen its own grant mid-run.
// The REPL is spawned as `neurosploit` with NO subcommand, so ONLY global
// flags are valid here — passing a `run`-subcommand flag (e.g. --environment,
// --budget, --compliance) at this position makes clap abort before the REPL
// even starts, which silently kills the engagement. The run-only knobs are
// delivered through the REPL script (buildReplScript) instead, or applied at
// their defaults. Keep this list to the `global = true` args in app/src/main.rs.
function authArgs(body) {
const args = [];
if (body.capability) args.push('--capability-token', body.capability);
for (const entry of body.inScope || []) args.push('--session-in-scope', entry);
if (body.environment) args.push('--session-environment', body.environment);
if (body.policyProfile) args.push('--session-policy', body.policyProfile);
// Egress and the OOB channel are launcher-level, like the grant: a session
// must not be able to re-route its own traffic once it is running.
if (body.transport && body.transport !== 'direct') args.push('--transport', body.transport);
if (body.oobDomain) args.push('--oob-domain', body.oobDomain);
if (body.oobHttp) args.push('--oob-http', body.oobHttp);
if (body.oobDns) args.push('--oob-dns', body.oobDns);
if (body.sms) args.push('--sms', body.sms);
if (body.typesafe) args.push('--typesafe', body.typesafe);
if (body.decisionBackend) args.push('--decision-backend', body.decisionBackend);
if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept);
if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox);
return args;
}
/// The non-secret subset of a launch body, kept so a job can be relaunched
/// after a server restart. API keys live only in memory (apiKeys) and creds
/// files on disk; neither is copied here.
function sanitizeLaunch(body) {
return {
mode: body.mode || 'run',
target: body.target || '',
repo: body.repo || '',
models: body.models || [],
subscription: !!body.subscription,
mcp: !!body.mcp,
votes: body.votes,
chainDepth: body.chainDepth,
recon: body.recon,
focus: body.focus,
objective: body.objective,
outOfScope: body.outOfScope,
agents: body.agents || [],
name: body.name || '',
sandbox: !!body.sandbox,
typesafe: body.typesafe,
quick: !!body.quick,
research: !!body.research,
maxAgents: body.maxAgents || undefined,
};
}
function buildReplScript(body) {
const lines = [];
if (body.mode === 'whitebox') lines.push(`/repo ${body.repo || body.target}`);
else {
if (body.target) lines.push(`/target ${body.target}`);
if (body.mode === 'greybox' && body.repo) lines.push(`/repo ${body.repo}`);
}
if ((body.models || []).length) lines.push(`/model ${body.models.join(',')}`);
lines.push(`/sub ${body.subscription ? 'on' : 'off'}`);
lines.push(`/mcp ${body.mcp ? 'on' : 'off'}`);
if (body.votes) lines.push(`/votes ${body.votes}`);
if (body.chainDepth !== undefined) lines.push(`/chain ${body.chainDepth}`);
if (body.recon) lines.push(`/recon ${body.recon}`);
if (body.maxAgents) lines.push(`/agents ${body.maxAgents}`);
if (body.focus) lines.push(`/focus ${body.focus}`);
if (body.objective) lines.push(`/objective ${body.objective}`);
if (body.outOfScope) lines.push(`/scope-out ${body.outOfScope}`);
if (body.creds) lines.push(`/creds ${body.creds}`);
if (body.collectionPath) lines.push(`/collection ${body.collectionPath}`);
if (body.research) lines.push(`/research on`);
// Settings the wizard collects that are NOT already applied as launch flags
// (authArgs) or the scope file. Emitted via the REPL's own commands so a web
// run enforces exactly what a typed session would. Capability, in-scope,
// intercept, sandbox, OOB, environment, policy and the scope allowlist are
// handled at launch in authArgs()/materializeScope() — do not re-emit them here.
if (body.classes) lines.push(`/class ${Array.isArray(body.classes) ? body.classes.join(',') : body.classes}`);
if (body.revalidatePoc) lines.push('/revalidate on');
if (body.budget && body.budget !== 'unlimited') lines.push(`/budget ${body.budget}`);
if (body.tokenLimit) lines.push(`/budget token ${body.tokenLimit}`);
for (const fw of body.compliance || []) lines.push(`/compliance ${fw}`);
lines.push((body.agents || []).length ? `/only ${body.agents.join(',')}` : '/only clear');
// Economy preset last, so it wins over the per-knob settings above.
if (body.quick) lines.push('/quick');
lines.push('/run');
return lines;
}
/// Same job abstraction as startJob(), but driven through a REAL interactive
/// REPL session instead of a one-shot `neurosploit run ...` subprocess — the
/// engagement streams identically (same underlying pipeline, same tagged
/// lines), but the session KEEPS reading stdin while it runs, so the web UI
/// can send more input mid-run (natural language, /status, /stop, /continue)
/// via POST /api/exploit/:id/input. Only run/whitebox/greybox support this —
/// host/aitest/skills need onboarding's scope picker, which is an interactive
/// arrow-key menu that skips itself entirely over a piped stdin.
async function startJobViaRepl(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 collectionPath = await materializeCollection(body, id);
const script = buildReplScript({ ...body, creds: credsPath, collectionPath });
const scopePath = await materializeScope(body, id);
const auth = authArgs({ ...body, scopePath });
const job = new Job(id, BIN, auth, body.repo || body.target || '', body.name || '');
job.pinnedAgents = body.agents || [];
job.repl = true;
// Non-secret params needed to relaunch this job after a restart. No API keys,
// no creds-file contents — only what rebuilds the engagement shape so a
// resumed REPL can `/continue` the harness checkpoint.
job.launch = sanitizeLaunch(body);
jobs.set(id, job);
persistJob(job);
// The REPL session inherits the engagement's authorization from argv, so the
// ceiling is set before the first command is scripted into it.
const child = spawn(BIN, auth, { 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);
// The REPL process itself exiting (e.g. after /quit) is ALSO a valid done
// signal, in addition to the content-based one in ingestLine().
child.on('close', (code) => {
if (buf.trim()) ingestLine(job, buf);
if (!job.done) { job.done = true; job.push({ type: 'done', exitCode: code }); }
job.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 });
});
for (const line of script) child.stdin.write(line + '\n');
return job;
}
/// Relaunch an interrupted REPL-backed job in place: spawn a fresh REPL over a
/// pipe, which (a) restores the project session (model/subscription) and (b)
/// auto-recovers the harness's on-disk checkpoint and `/continue`s it, carrying
/// the prior findings forward. We reuse the SAME job object (id, feed,
/// findings) so the browser's live view simply resumes streaming.
function relaunchJobViaRepl(job) {
const launch = job.launch || {};
const auth = authArgs(launch);
job.repl = true;
job.done = false;
job.exitCode = null;
job.interrupted = false;
job.phase = 'resuming';
job.push({ type: 'log', line: '[web] resuming — recovering the on-disk checkpoint and continuing…' });
const child = spawn(BIN, auth, { cwd: ROOT, env: { ...process.env, ...envOverrides(), NEUROSPLOIT_AUTO_RESUME: '1' } });
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);
if (!job.done) { job.done = true; job.push({ type: 'done', exitCode: code }); }
job.exitCode = code;
});
child.on('error', (err) => {
job.done = true;
job.push({ type: 'log', line: `[web] failed to resume neurosploit: ${err.message}` });
job.push({ type: 'done', exitCode: -1 });
});
// Re-apply the engagement shape, then continue. Over a pipe the REPL also
// auto-continues on its own; a second /continue while working is a harmless
// no-op. Sending the settings first makes the resumed run deterministic even
// if the saved session was stale.
const script = [];
if (launch.subscription !== undefined) script.push(`/sub ${launch.subscription ? 'on' : 'off'}`);
if ((launch.models || []).length) script.push(`/model ${launch.models.join(',')}`);
script.push('/continue');
for (const line of script) child.stdin.write(line + '\n');
persistJob(job);
return job;
}
/// Rebuild the in-memory job list from disk on startup. A job that was still
/// running when the server stopped is marked interrupted (and resumable when it
/// was a REPL-backed run/whitebox/greybox job, since only those have the
/// harness checkpoint + /continue path).
function loadPersistedJobs() {
let files = [];
try { files = fs.readdirSync(JOBS_DIR).filter((f) => f.endsWith('.json')); } catch { return; }
for (const f of files) {
let rec;
try { rec = JSON.parse(fs.readFileSync(path.join(JOBS_DIR, f), 'utf8')); } catch { continue; }
if (!rec || !rec.id) continue;
const job = new Job(rec.id, BIN, [], rec.target || '', rec.name || '');
job.runId = rec.runId || null;
job.findings = rec.findings || [];
job.agents = rec.agents || 0;
job.agentsDone = rec.agentsDone || 0;
job.reportUrl = rec.reportUrl || null;
job.startedAt = rec.startedAt || Date.now();
job.repl = !!rec.interactive;
job.launch = rec.launch || null;
job.feed = rec.feedTail || [];
job.child = null;
if (rec.done) {
job.done = true;
job.phase = rec.phase || 'complete';
} else {
// The server died while this was live. It can't still be running.
job.done = false;
job.interrupted = true;
job.phase = 'interrupted';
const m = (rec.launch && rec.launch.mode) || 'run';
job.resumable = job.repl && ['run', 'whitebox', 'greybox'].includes(m);
}
jobs.set(job.id, job);
}
}
// ---------------------------------------------------------------------------
// REPL sessions — spawn `neurosploit` with no subcommand (Reader::Plain kicks
// in over a piped stdin) and forward stdin/stdout verbatim: a real REPL.
//
// The stream is NOT ANSI-stripped: the browser renders it in xterm.js, which
// is the same terminal emulator a local shell would drive, so colour, cursor
// moves and the harness's own spinners arrive intact. Stripping here would
// hand the emulator a degraded copy of what the CLI actually printed.
// ---------------------------------------------------------------------------
const replSessions = new Map();
class ReplSession extends EventEmitter {
constructor(id, child) {
super();
this.id = id;
this.child = child;
this.done = false;
this.buffer = [];
this.bytes = 0;
}
push(chunk) {
this.buffer.push(chunk);
this.bytes += chunk.length;
// Bound the replay buffer by size, not chunk count: one chunk can be a
// single keystroke echo or a whole recon dump, so counting chunks caps
// memory nowhere near where it's meant to.
while (this.bytes > 512_000 && this.buffer.length > 1) {
this.bytes -= this.buffer.shift().length;
}
this.emit('data', chunk);
}
}
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,
// Without TERM the harness assumes a dumb terminal and drops colour; the
// browser side is a full xterm, so say so.
env: { ...process.env, ...envOverrides(), TERM: 'xterm-256color' },
});
const session = new ReplSession(id, child);
replSessions.set(id, session);
// One decoder per stream: a chunk boundary can land mid-UTF-8-sequence, and
// decoding each chunk independently would emit replacement characters.
const decOut = new StringDecoder('utf8');
const decErr = new StringDecoder('utf8');
child.stdout.on('data', (c) => session.push(decOut.write(c)));
child.stderr.on('data', (c) => session.push(decErr.write(c)));
child.on('close', (code) => {
session.done = true;
session.push(`\r\n\x1b[2m[repl session ended, exit code ${code}]\x1b[0m\r\n`);
session.emit('close');
});
child.on('error', (err) => {
session.done = true;
session.push(`\r\n\x1b[31m[failed to start neurosploit: ${err.message}]\x1b[0m\r\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);
// Separator-aware: `ns-1` must not reach into `ns-10`.
if (full !== dir && !full.startsWith(dir + path.sep)) { 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());
}
if (req.method === 'POST' && p === '/api/leads/generate') {
const body = await readBody(req);
const description = (body.description || '').trim();
if (!description) return sendJson(res, 400, { error: 'description is required' });
let raw;
try {
raw = await generateCustomLead(description);
} catch (e) {
return sendJson(res, 502, { error: e.message });
}
// Defensive: discard any wrapper text before the first '# ' heading —
// a model with tool access sometimes narrates ("I'll write the file
// now...") before the actual content despite being told not to.
const titleIdx = raw.search(/^#\s+/m);
if (titleIdx === -1) {
return sendJson(res, 502, { error: 'generation did not return a well-formed agent file', raw: raw.slice(0, 800) });
}
const clean = raw.slice(titleIdx).trim();
const titleMatch = clean.match(/^#\s+(.+?)\s*$/m);
if (!titleMatch || !/##\s*User Prompt/i.test(clean) || !/##\s*System Prompt/i.test(clean)) {
return sendJson(res, 502, { error: 'generation did not return a well-formed agent file', raw: raw.slice(0, 800) });
}
const slug = `custom_${slugify(titleMatch[1])}`;
await fsp.writeFile(path.join(AGENTS_DIR, 'vulns', `${slug}.md`), clean + '\n');
agentCache = null; // force a fresh read so the new lead shows up immediately
const { agents } = await loadAgents();
const created = agents.find((a) => a.id === slug);
return sendJson(res, 200, { agent: created, raw: clean });
}
// ---- custom lead from the operator's OWN agent markdown (verbatim) ----
if (req.method === 'POST' && p === '/api/leads/custom') {
const body = await readBody(req);
const title = (body.title || '').trim();
let md = (body.markdown || '').trim();
if (!md) return sendJson(res, 400, { error: 'paste the agent markdown (or a system prompt)' });
// Accept a bare system prompt too: wrap it into the minimal agent shape so
// it is a real, pinnable agent like every other lead.
if (!/^#\s+/m.test(md) || !/##\s*User Prompt/i.test(md) || !/##\s*System Prompt/i.test(md)) {
const name = title || 'Custom Lead';
md = `# ${name} Agent\n## User Prompt\nYou are testing **{target}**.\n**Recon Context:**\n{recon_json}\n\n${md}\n\n### Report\n\`\`\`\nFINDING:\n- Title: ...\n- Severity: ...\n- CWE: CWE-...\n- Endpoint: [URL]\n- Evidence: ...\n- Impact: ...\n- Remediation: ...\n\`\`\`\n## System Prompt\n${md}\n`;
}
const titleMatch = md.match(/^#\s+(.+?)\s*$/m);
const slug = `custom_${slugify(title || (titleMatch && titleMatch[1]) || 'lead')}`;
await fsp.writeFile(path.join(AGENTS_DIR, 'vulns', `${slug}.md`), md.trim() + '\n');
agentCache = null;
const { agents } = await loadAgents();
return sendJson(res, 200, { agent: agents.find((a) => a.id === slug), raw: md });
}
// ---- delete a custom lead (only the operator's own custom_* agents) ----
const delLead = p.match(/^\/api\/leads\/([^/]+)$/);
if (req.method === 'DELETE' && delLead) {
const id = decodeURIComponent(delLead[1]);
if (!/^custom_[a-z0-9_]+$/.test(id)) return sendJson(res, 400, { error: 'only custom leads can be removed' });
const file = path.join(AGENTS_DIR, 'vulns', `${id}.md`);
if (!file.startsWith(path.join(AGENTS_DIR, 'vulns') + path.sep) || !fs.existsSync(file)) {
return sendJson(res, 404, { error: 'custom lead not found' });
}
await fsp.rm(file, { force: true });
agentCache = null;
return sendJson(res, 200, { ok: true, removed: id });
}
// ---- 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]));
}
// Delete ALL runs (must come before the single-run matcher below).
if (req.method === 'DELETE' && p === '/api/runs') {
const n = await deleteAllRuns();
return sendJson(res, 200, { ok: true, deleted: n });
}
m = p.match(/^\/api\/runs\/([^/]+)$/);
if (req.method === 'DELETE' && m) {
const id = decodeURIComponent(m[1]);
const ok = await deleteRun(id);
if (!ok) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, { ok: true, deleted: 1, id });
}
// ---- 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);
// run/whitebox/greybox go through a real REPL session so the operator
// can keep sending it input while it streams; host/aitest/skills need
// the onboarding scope picker (an interactive menu that only works on
// a real TTY), so they stay on the plain one-shot CLI subprocess.
const replCapable = ['run', 'whitebox', 'greybox'].includes(body.mode || 'run');
const job = replCapable ? await startJobViaRepl(body) : await startJob(body);
return sendJson(res, 200, { id: job.id, interactive: !!job.repl });
}
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\/([^/]+)\/resume$/);
if (req.method === 'POST' && m) {
const job = jobs.get(m[1]);
if (!job) return sendJson(res, 404, { error: 'job not found' });
if (!job.interrupted) return sendJson(res, 409, { error: 'this job is not interrupted — nothing to resume' });
if (!job.resumable) return sendJson(res, 409, { error: 'this job cannot be resumed (only run/whitebox/greybox engagements checkpoint)' });
if (!BIN) return sendJson(res, 500, { error: 'neurosploit binary not found' });
// API-key jobs need their provider key back after a server restart; it
// lived only in memory. Subscription jobs need no key.
const launch = job.launch || {};
if (!launch.subscription) {
const provs = (launch.models || []).map((m2) => String(m2).split(':')[0]);
const missing = provs.filter((pr) => PROVIDERS.some((P) => P.key === pr) && !apiKeys.get(pr) && !process.env[(PROVIDERS.find((P) => P.key === pr) || {}).envKey]);
if (missing.length) {
return sendJson(res, 409, { error: `set the API key for ${[...new Set(missing)].join(', ')} again (it is kept only in memory), then resume` });
}
}
relaunchJobViaRepl(job);
return sendJson(res, 200, { ok: true, id: job.id, interactive: true });
}
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' });
const child = job.child;
const alive = !!child && child.exitCode === null && !child.killed;
if (!alive) {
// Nothing left to stop (the run already exited — e.g. it failed to
// launch). Mark it done so the UI stops showing it as running.
if (!job.done) { job.done = true; job.phase = 'stopped'; job.push({ type: 'done', exitCode: job.exitCode ?? 0 }); }
return sendJson(res, 200, { ok: true, note: 'already stopped' });
}
if (job.repl && child.stdin && child.stdin.writable) {
// Graceful: /stop then "1" — validate what's found so far, then report.
// A second press (or the fallback below) escalates to a signal.
if (job._stopping) { child.kill('SIGTERM'); }
else {
job._stopping = true;
job.phase = 'stopping';
job.push({ type: 'log', line: '[web] stopping — validating findings so far, then reporting…' });
try { child.stdin.write('/stop\n1\n'); } catch { child.kill('SIGTERM'); }
// If the REPL hasn't exited in time, don't leave it hanging.
setTimeout(() => { if (child.exitCode === null && !child.killed) child.kill('SIGTERM'); }, 45000).unref?.();
setTimeout(() => { if (child.exitCode === null && !child.killed) child.kill('SIGKILL'); }, 60000).unref?.();
}
} else {
child.kill('SIGINT');
setTimeout(() => { if (child.exitCode === null && !child.killed) child.kill('SIGTERM'); }, 5000).unref?.();
setTimeout(() => { if (child.exitCode === null && !child.killed) child.kill('SIGKILL'); }, 10000).unref?.();
}
return sendJson(res, 200, { ok: true });
}
// Pause / resume / report-where-it-stopped. All three are REPL commands,
// so they only exist on a REPL-backed job — a one-shot CLI subprocess has
// no stdin listener to take them.
m = p.match(/^\/api\/exploit\/([^/]+)\/(pause|continue|report)$/);
if (req.method === 'POST' && m) {
const job = jobs.get(m[1]);
if (!job) return sendJson(res, 404, { error: 'job not found' });
if (!job.repl || !job.child?.stdin?.writable) {
return sendJson(res, 409, { error: 'this job is not an interactive session — pause/continue/report need a REPL-backed run (run, whitebox or greybox)' });
}
const cmd = { pause: '/pause', continue: '/continue', report: '/report' }[m[2]];
job.child.stdin.write(cmd + '\n');
if (m[2] === 'pause') job.phase = 'paused (operator)';
else if (m[2] === 'continue' && job.phase.startsWith('paused')) job.phase = 'resuming';
return sendJson(res, 200, { ok: true, sent: cmd });
}
// The whole log, as text — for downloading or pasting into a ticket.
m = p.match(/^\/api\/exploit\/([^/]+)\/log$/);
if (req.method === 'GET' && m) {
const job = jobs.get(m[1]);
if (!job) return sendJson(res, 404, { error: 'job not found' });
const body = job.feed.filter((e) => e.type === 'log').map((e) => e.line).join('\n') + '\n';
res.writeHead(200, {
'content-type': 'text/plain; charset=utf-8',
'content-disposition': `attachment; filename="neurosploit-${job.runId || job.id}.log"`,
});
return res.end(body);
}
m = p.match(/^\/api\/exploit\/([^/]+)\/input$/);
if (req.method === 'POST' && m) {
const job = jobs.get(m[1]);
if (!job) return sendJson(res, 404, { error: 'job not found' });
if (!job.repl || !job.child?.stdin?.writable) {
return sendJson(res, 409, { error: 'this job is not an interactive session (host/aitest/skills engagements run non-interactively)' });
}
const body = await readBody(req);
job.child.stdin.write(String(body.line ?? '') + '\n');
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);
// `data` is raw (whatever the terminal captured); `line` is the older
// line-oriented form and still gets its newline appended here.
const raw = body.data != null ? String(body.data) : String(body.line ?? '') + '\n';
// Ctrl-C over a pipe is not a signal — nothing turns byte 0x03 into
// SIGINT when there is no tty in between, so the interrupt has to be
// delivered explicitly or it would silently do nothing.
if (raw.includes('\x03')) {
session.child.kill('SIGINT');
return sendJson(res, 200, { ok: true, signalled: 'SIGINT' });
}
if (!session.child.stdin.writable) return sendJson(res, 409, { error: 'session has ended' });
session.child.stdin.write(raw);
return sendJson(res, 200, { ok: true });
}
m = p.match(/^\/api\/repl\/([^/]+)\/stop$/);
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;
}
// ---- attack graph / traffic / traces (files the CLI wrote) ----
m = p.match(/^\/api\/runs\/([^/]+)\/graph$/);
if (m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
if (req.method === 'GET') return sendJson(res, 200, await runGraph(dir));
if (req.method === 'POST') {
// Recompute (and optionally push to Neo4j) through the CLI itself.
const body = await readBody(req);
const args = ['graph', dir, '--top', '0'];
if (body.push) args.push('--push');
const out = await runCli(args);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 'graph failed' });
return sendJson(res, 200, { ok: true, output: out.buf.trim(), ...(await runGraph(dir)) });
}
}
m = p.match(/^\/api\/runs\/([^/]+)\/traffic$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await runTraffic(dir, u.searchParams));
}
m = p.match(/^\/api\/runs\/([^/]+)\/traces$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await searchTraces(dir, u.searchParams.get('q')));
}
// ---- PoC re-test (the CLI re-runs each finding's recorded proof) ----
m = p.match(/^\/api\/runs\/([^/]+)\/retest$/);
if (req.method === 'POST' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
const body = await readBody(req).catch(() => ({}));
const only = typeof body.finding === 'string' && body.finding ? body.finding : null;
// Per-finding re-test asks the CLI for JSON and keeps a dated history so
// the operator can see a finding drift from reproduced to gone over time.
if (only) {
const out = await runCli(['poc', dir, '--only', only, '--json']);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 're-test failed' });
let result = null;
try { result = (JSON.parse(lastJsonLine(out.buf)) || [])[0] || null; } catch { /* keep raw */ }
const entry = { at: Math.floor(Date.now() / 1000), ...(result || {}), raw: result ? undefined : out.buf.trim() };
const history = await appendRetest(dir, only, entry);
return sendJson(res, 200, { ok: true, result: entry, history });
}
const out = await runCli(['poc', dir]);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 're-test failed' });
return sendJson(res, 200, { ok: true, output: out.buf.trim() });
}
// ---- per-finding re-test history ----
m = p.match(/^\/api\/runs\/([^/]+)\/retests\/([^/]+)$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, { history: await readRetests(dir, decodeURIComponent(m[2])) });
}
// ---- regression vs the previous run of the same target ----
m = p.match(/^\/api\/runs\/([^/]+)\/regression$/);
if (req.method === 'GET' && m) {
const id = decodeURIComponent(m[1]);
const dir = safeRunDir(id);
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await runRegression(dir, id));
}
// ---- recorded traffic that backs one finding ----
m = p.match(/^\/api\/runs\/([^/]+)\/findings\/([^/]+)\/traffic$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await findingTraffic(dir, decodeURIComponent(m[2])));
}
// ---- report rebuild (generate/refresh the PDF for a finished run) ----
m = p.match(/^\/api\/runs\/([^/]+)\/report$/);
if (req.method === 'POST' && m) {
const id = decodeURIComponent(m[1]);
const dir = safeRunDir(id);
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
if (!BIN) return sendJson(res, 500, { error: 'neurosploit binary not found — run `cargo build --release` in neurosploit-rs/' });
// The harness owns report generation (Typst template, severity ordering,
// the evidence sections); shelling out to it keeps one implementation
// instead of a second, drifting one in JavaScript.
const out = await new Promise((resolve) => {
const child = spawn(BIN, ['rebuild', dir], { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
let buf = '';
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
child.stderr.on('data', (c) => { buf += c.toString('utf8'); });
child.on('close', (code) => resolve({ code, buf }));
child.on('error', (e) => resolve({ code: -1, buf: e.message }));
});
const built = ['report.pdf', 'report.html', 'report.md', 'report.json'].filter((f) => fs.existsSync(path.join(dir, f)));
if (out.code !== 0 && !built.includes('report.pdf')) {
return sendJson(res, 502, { error: stripAnsi(out.buf).trim() || 'rebuild failed', built });
}
return sendJson(res, 200, {
ok: true,
built,
// Typst is optional; saying so beats handing back a link to a file that
// was never produced.
pdf: built.includes('report.pdf'),
note: built.includes('report.pdf') ? '' : 'PDF needs the `typst` binary on PATH — the HTML and Markdown reports were rebuilt.',
});
}
// ---- aggregate stats for the dashboard ----
if (req.method === 'GET' && p === '/api/stats') {
return sendJson(res, 200, await stats());
}
if (req.method === 'GET' && p === '/api/meta') {
return sendJson(res, 200, { version: VERSION, 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);
// Save also verifies the key with a cheap authenticated call, so the
// operator knows it works before a run depends on it. The key is saved
// either way (some providers can't be auto-verified from here).
let verify = { ok: null, message: 'saved' };
if (body.key && body.verify !== false) verify = await verifyKey(body.provider, body.key);
return sendJson(res, 200, { ok: true, verify });
}
if (req.method === 'POST' && p === '/api/keys/verify') {
const body = await readBody(req);
const key = body.key || apiKeys.get(body.provider);
if (!body.provider || !PROVIDERS.some((pr) => pr.key === body.provider)) return sendJson(res, 400, { error: 'unknown provider' });
if (!key) return sendJson(res, 400, { error: 'no key to verify' });
return sendJson(res, 200, await verifyKey(body.provider, key));
}
m = p.match(/^\/api\/keys\/([^/]+)$/);
if (req.method === 'DELETE' && m) {
apiKeys.delete(decodeURIComponent(m[1]));
return sendJson(res, 200, { ok: true });
}
// ---- Issues (aggregate findings across all runs) ----
if (req.method === 'GET' && p === '/api/issues') {
const dirs = await fsp.readdir(RUNS_DIR).catch(() => []);
const all = [];
for (const d of dirs) {
const fp = path.join(RUNS_DIR, d, 'findings.json');
try {
const raw = await fsp.readFile(fp, 'utf8');
const findings = JSON.parse(raw);
let runTarget = d;
try { const meta = JSON.parse(await fsp.readFile(path.join(RUNS_DIR, d, 'meta.json'), 'utf8')); runTarget = meta.target || d; } catch {}
for (const f of findings) all.push({ ...f, run: d, target: f.target || runTarget });
} catch { /* skip */ }
}
const sRank = (s) => { s = (s || '').toLowerCase(); return s.includes('crit') ? 0 : s.includes('high') ? 1 : s.includes('med') ? 2 : s.includes('low') ? 3 : 4; };
all.sort((a, b) => sRank(a.severity) - sRank(b.severity));
return sendJson(res, 200, all);
}
// ---- Config CRUD: repos ----
if (req.method === 'GET' && p === '/api/config/repos') return sendJson(res, 200, await readConfig('repos'));
if (req.method === 'POST' && p === '/api/config/repos') {
const body = await readBody(req);
const repos = await readConfig('repos');
const item = { id: crypto.randomUUID(), ...body, createdAt: new Date().toISOString() };
repos.push(item);
await writeConfig('repos', repos);
return sendJson(res, 200, item);
}
m = p.match(/^\/api\/config\/repos\/([^/]+)$/);
if (req.method === 'DELETE' && m) {
let repos = await readConfig('repos');
repos = repos.filter(r => r.id !== decodeURIComponent(m[1]));
await writeConfig('repos', repos);
return sendJson(res, 200, { ok: true });
}
// ---- Config CRUD: domains ----
if (req.method === 'GET' && p === '/api/config/domains') return sendJson(res, 200, await readConfig('domains'));
if (req.method === 'POST' && p === '/api/config/domains') {
const body = await readBody(req);
const domains = await readConfig('domains');
const item = { id: crypto.randomUUID(), ...body, createdAt: new Date().toISOString() };
domains.push(item);
await writeConfig('domains', domains);
return sendJson(res, 200, item);
}
m = p.match(/^\/api\/config\/domains\/([^/]+)$/);
if (req.method === 'DELETE' && m) {
let domains = await readConfig('domains');
domains = domains.filter(d => d.id !== decodeURIComponent(m[1]));
await writeConfig('domains', domains);
return sendJson(res, 200, { ok: true });
}
// ---- Config CRUD: networks ----
if (req.method === 'GET' && p === '/api/config/networks') return sendJson(res, 200, await readConfig('networks'));
if (req.method === 'POST' && p === '/api/config/networks') {
const body = await readBody(req);
const networks = await readConfig('networks');
const item = { id: crypto.randomUUID(), ...body, createdAt: new Date().toISOString() };
networks.push(item);
await writeConfig('networks', networks);
return sendJson(res, 200, item);
}
m = p.match(/^\/api\/config\/networks\/([^/]+)$/);
if (req.method === 'DELETE' && m) {
let networks = await readConfig('networks');
networks = networks.filter(n => n.id !== decodeURIComponent(m[1]));
await writeConfig('networks', networks);
return sendJson(res, 200, { ok: true });
}
// ---- Config CRUD: integrations ----
if (req.method === 'GET' && p === '/api/config/integrations') return sendJson(res, 200, await readConfig('integrations'));
m = p.match(/^\/api\/config\/integrations\/([^/]+)$/);
if (req.method === 'PUT' && m) {
const body = await readBody(req);
const id = decodeURIComponent(m[1]);
const cfg = body.config || {};
const validation = await validateIntegration(id, cfg);
let integs = await readConfig('integrations');
const idx = integs.findIndex(i => i.id === id);
const entry = { id, ...body, validation };
if (idx >= 0) integs[idx] = { ...integs[idx], ...entry };
else integs.push(entry);
await writeConfig('integrations', integs);
return sendJson(res, 200, { ...integs.find(i => i.id === id), validation });
}
// ---- Config: settings ----
if (req.method === 'GET' && p === '/api/config/settings') return sendJson(res, 200, await readConfigObj('settings'));
if (req.method === 'PUT' && p === '/api/config/settings') {
const body = await readBody(req);
await writeConfig('settings', body);
return sendJson(res, 200, body);
}
// ---- Config: vault ----
if (req.method === 'GET' && p === '/api/config/vault') return sendJson(res, 200, await readConfigObj('vault'));
if (req.method === 'PUT' && p === '/api/config/vault') {
const body = await readBody(req);
await writeConfig('vault', body);
return sendJson(res, 200, body);
}
// ---- Config: guardrails ----
if (req.method === 'GET' && p === '/api/config/guardrails') return sendJson(res, 200, await readConfigObj('guardrails'));
if (req.method === 'PUT' && p === '/api/config/guardrails') {
const body = await readBody(req);
await writeConfig('guardrails', body);
return sendJson(res, 200, body);
}
sendJson(res, 404, { error: 'not found' });
} catch (err) {
sendJson(res, 500, { error: err.message });
}
});
loadPersistedJobs();
server.listen(PORT, () => {
console.log(`NeuroSploit v${VERSION} 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}`);
const resumable = [...jobs.values()].filter((j) => j.interrupted && j.resumable).length;
if (resumable) console.log(` jobs : ${resumable} interrupted run(s) can be resumed`);
});