mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-26 11:22:37 +02:00
- Select all / Clear all buttons in the Leads step toolbar - respects the
current search filter, so filtering to "sql" then Select all only pins
those, not all 412 leads. The per-category master switch (already
select/deselect-all for that category, indeterminate when partial) was
the only bulk control before; this adds the "everything" case.
- '+ Custom lead' now generates an ACTUAL specialist-agent markdown file
(agents_md/vulns/custom_<slug>.md, same format every other agent uses)
via the claude CLI on the operator's Anthropic subscription
(claude-opus-4-8 by default - matches the harness's own default model),
instead of folding free text into --focus. The new lead is immediately
selectable and pinnable via --only like any other agent; verified the
Rust harness's own agent loader picks it up (agent count went 435 -> 436,
neurosploit agents confirmed it).
Two things found and fixed while wiring this up:
- the skip-permissions flag gave the model file/bash tool access, which
made it try to write the file itself and narrate doing so instead of
just returning text. Dropped the flag (pure text completion needs no
tools) and told it explicitly not to use any.
- Even so, defensively strip anything before the first '# ' heading
before saving, in case a model still prepends commentary.
Falls back to the old free-text-focus behavior if generation fails
(claude not installed/logged in, malformed output, timeout) so the
operator's intent isn't lost.
- New "Custom Leads" category, shown first, so generated leads have a
visible home instead of landing in the catch-all "Other" bucket.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
867 lines
37 KiB
JavaScript
867 lines
37 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
/**
|
|
* NeuroSploit v4.0.0 — web console backend.
|
|
*
|
|
* Zero-dependency Node HTTP server that:
|
|
* - serves the static SPA in ./public
|
|
* - reads agents_md/ to build the "lead board" (agent/category picker)
|
|
* - reads runs/ to build run history + structured findings
|
|
* - spawns the compiled `neurosploit` CLI binary for exploitation runs and
|
|
* streams its stdout (parsed into structured events) over SSE
|
|
* - spawns the interactive `neurosploit` REPL (no subcommand) as a child
|
|
* process and pipes stdin/stdout so the browser gets a REAL REPL
|
|
* connected to the CLI harness — not a reimplementation.
|
|
*/
|
|
|
|
const http = require('node:http');
|
|
const fs = require('node:fs');
|
|
const fsp = fs.promises;
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawn } = require('node:child_process');
|
|
const crypto = require('node:crypto');
|
|
const { EventEmitter } = require('node:events');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WEB_DIR = __dirname;
|
|
const ROOT = path.resolve(WEB_DIR, '..'); // repo root — holds agents_md/, runs/
|
|
const AGENTS_DIR = path.join(ROOT, 'agents_md');
|
|
const RUNS_DIR = path.join(ROOT, 'runs');
|
|
const NAMES_FILE = path.join(ROOT, '.neurosploit', 'web-engagement-names.json');
|
|
|
|
// Engagement names are set by the operator in the wizard before launch (not
|
|
// something the CLI/harness knows about) — persisted here as runId -> name so
|
|
// the sidebar/run history can label a run by its engagement name across
|
|
// restarts, not just by target/run-id.
|
|
const engagementNames = new Map();
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(NAMES_FILE, 'utf8'));
|
|
for (const [k, v] of Object.entries(raw)) engagementNames.set(k, v);
|
|
} catch { /* no file yet — fine */ }
|
|
|
|
function saveEngagementName(runId, name) {
|
|
if (!runId || !name) return;
|
|
engagementNames.set(runId, name);
|
|
fsp.mkdir(path.dirname(NAMES_FILE), { recursive: true })
|
|
.then(() => fsp.writeFile(NAMES_FILE, JSON.stringify(Object.fromEntries(engagementNames), null, 2)))
|
|
.catch(() => {});
|
|
}
|
|
const PUBLIC_DIR = path.join(WEB_DIR, 'public');
|
|
|
|
function findBinary() {
|
|
const candidates = [
|
|
path.join(ROOT, 'neurosploit-rs', 'target', 'release', 'neurosploit'),
|
|
path.join(ROOT, 'neurosploit-rs', 'target', 'debug', 'neurosploit'),
|
|
];
|
|
for (const c of candidates) {
|
|
if (fs.existsSync(c)) return c;
|
|
}
|
|
return null;
|
|
}
|
|
const BIN = findBinary();
|
|
|
|
const PORT = Number(process.env.NEUROSPLOIT_WEB_PORT || process.env.PORT || 4173);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Providers — mirrors crates/harness/src/models.rs `providers()`. Kept as a
|
|
// literal table (not parsed from CLI output) so `kind` ("cli" = usable via a
|
|
// locally-installed agentic CLI subscription login, "api" = key-only) and
|
|
// `envKey` (the environment variable the harness reads for that provider)
|
|
// are available without shelling out. Keep this in sync when models.rs adds
|
|
// a provider.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PROVIDERS = [
|
|
{ key: 'anthropic', label: 'Anthropic Claude', kind: 'cli', envKey: 'ANTHROPIC_API_KEY',
|
|
models: ['claude-opus-5', 'claude-sonnet-5', 'claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'] },
|
|
{ key: 'openai', label: 'OpenAI (ChatGPT)', kind: 'cli', envKey: 'OPENAI_API_KEY',
|
|
models: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', 'gpt-5.1', 'gpt-5.1-codex', 'o4'] },
|
|
{ key: 'xai', label: 'xAI Grok', kind: 'cli', envKey: 'XAI_API_KEY',
|
|
models: ['grok-4.5', 'grok-4', 'grok-4-fast'] },
|
|
{ key: 'gemini', label: 'Google Gemini', kind: 'cli', envKey: 'GEMINI_API_KEY',
|
|
models: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'] },
|
|
{ key: 'opencode', label: 'OpenCode Zen', kind: 'cli', envKey: 'OPENCODE_API_KEY',
|
|
models: ['claude-opus-5', 'claude-sonnet-5', 'gpt-5.6-sol', 'gpt-5.5', 'gemini-3-pro', 'grok-4.5', 'deepseek-v4-pro', 'qwen3.7-max', 'kimi-k3'] },
|
|
{ key: 'nous', label: 'Nous Research (Hermes)', kind: 'cli', envKey: 'NOUS_API_KEY',
|
|
models: ['Hermes-4-405B', 'Hermes-4-70B', 'DeepHermes-3-Mistral-24B-Preview'] },
|
|
{ key: 'nvidia_nim', label: 'NVIDIA NIM', kind: 'api', envKey: 'NVIDIA_NIM_API_KEY',
|
|
models: ['nvidia/llama-3.3-nemotron-super-49b-v1', 'deepseek-ai/deepseek-r1', 'qwen/qwen2.5-coder-32b-instruct'] },
|
|
{ key: 'deepseek', label: 'DeepSeek', kind: 'api', envKey: 'DEEPSEEK_API_KEY',
|
|
models: ['deepseek-reasoner', 'deepseek-chat'] },
|
|
{ key: 'mistral', label: 'Mistral', kind: 'api', envKey: 'MISTRAL_API_KEY',
|
|
models: ['mistral-large-latest', 'codestral-latest'] },
|
|
{ key: 'qwen', label: 'Qwen (DashScope)', kind: 'api', envKey: 'DASHSCOPE_API_KEY',
|
|
models: ['qwen-max', 'qwen2.5-coder-32b-instruct', 'qwq-plus'] },
|
|
{ key: 'groq', label: 'Groq', kind: 'api', envKey: 'GROQ_API_KEY',
|
|
models: ['llama-3.3-70b-versatile', 'qwen-2.5-coder-32b'] },
|
|
{ key: 'together', label: 'Together AI', kind: 'api', envKey: 'TOGETHER_API_KEY',
|
|
models: ['Qwen/Qwen2.5-Coder-32B-Instruct', 'deepseek-ai/DeepSeek-R1', 'meta-llama/Llama-3.3-70B-Instruct-Turbo'] },
|
|
{ key: 'moonshot', label: 'Moonshot AI (Kimi)', kind: 'api', envKey: 'MOONSHOT_API_KEY',
|
|
models: ['kimi-k3', 'kimi-k2', 'moonshot-v1-128k', 'moonshot-v1-32k'] },
|
|
{ key: 'litellm', label: 'LiteLLM (proxy)', kind: 'api', envKey: 'LITELLM_API_KEY',
|
|
models: ['gpt-4o', 'claude-3-7-sonnet', 'gemini/gemini-2.5-pro'] },
|
|
{ key: 'openrouter', label: 'OpenRouter', kind: 'api', envKey: 'OPENROUTER_API_KEY',
|
|
models: ['anthropic/claude-opus-4-8', 'qwen/qwen-2.5-coder-32b-instruct', 'deepseek/deepseek-r1', 'meta-llama/llama-3.3-70b-instruct'] },
|
|
{ key: 'azure', label: 'Azure OpenAI', kind: 'api', envKey: 'AZURE_OPENAI_API_KEY',
|
|
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-5.1', 'o4-mini'] },
|
|
{ key: 'ollama', label: 'Ollama (local)', kind: 'api', envKey: 'OLLAMA_API_KEY',
|
|
models: ['qwen2.5-coder:32b', 'qwq:32b', 'deepseek-r1:32b', 'llama3.3:70b'] },
|
|
{ key: 'llamacpp', label: 'llama.cpp (local)', kind: 'api', envKey: 'LLAMACPP_API_KEY',
|
|
models: ['qwen2.5-coder-32b-instruct', 'dolphin-2.9-llama3-70b', 'deepseek-r1-distill-qwen-32b', 'llama-3.3-70b-instruct'] },
|
|
];
|
|
|
|
// In-memory only — never written to disk. Cleared on server restart.
|
|
const apiKeys = new Map(); // provider key -> secret
|
|
|
|
function envOverrides() {
|
|
const env = {};
|
|
for (const [key, secret] of apiKeys) {
|
|
const p = PROVIDERS.find((x) => x.key === key);
|
|
if (p && secret) env[p.envKey] = secret;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/// Build a minimal creds.yaml-compatible file (see neurosploit-rs/creds.example.yaml)
|
|
/// from a raw auth header and/or named roles, so the CLI's --creds flag can be
|
|
/// used to carry web-entered auth material without a real file on disk.
|
|
function buildCredsYaml({ auth, roles }) {
|
|
const lines = ['# generated by the NeuroSploit web console — ephemeral, not committed'];
|
|
if (auth) lines.push(`header: ${JSON.stringify(auth)}`);
|
|
for (const r of roles || []) {
|
|
if (!r?.name || !r?.header) continue;
|
|
const safe = String(r.name).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
lines.push(`${safe}:`, ` header: ${JSON.stringify(r.header)}`);
|
|
}
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
async function materializeCreds(body, jobId) {
|
|
if (body.creds) return body.creds; // explicit file path on disk wins
|
|
if (!body.auth && !(body.roles || []).length) return undefined;
|
|
const dir = path.join(os.tmpdir(), 'neurosploit-web');
|
|
await fsp.mkdir(dir, { recursive: true });
|
|
const file = path.join(dir, `${jobId}.creds.yaml`);
|
|
await fsp.writeFile(file, buildCredsYaml(body));
|
|
return file;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent library — read agents_md/{vulns,ai,infra,code,chains,recon,meta}/*.md
|
|
// and classify each into a lead category the UI can group + toggle.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const KIND_DIRS = {
|
|
vulns: 'vuln',
|
|
ai: 'ai',
|
|
infra: 'infra',
|
|
code: 'code',
|
|
chains: 'chain',
|
|
recon: 'recon',
|
|
meta: 'meta',
|
|
};
|
|
|
|
// Ordered classifier: first matching rule wins. Mirrors the vocabulary used
|
|
// throughout agents_md/ so every agent lands in a sensible bucket for the
|
|
// lead board (screenshot-style category groups).
|
|
const CATEGORY_RULES = [
|
|
[/^(llm_|mcp_|n8n_|redteam_|skill_|prompt_injection|ml_model_inversion|vector_db_injection)/, 'LLM Application'],
|
|
[/^(xss_|dom_xss|blind_xss|mutation_xss|dom_clobbering|postmessage_vulnerability)/, 'Cross-Site Scripting'],
|
|
[/^(jwt_|oauth_|oidc_|saml_|session_fixation|mfa_bypass|two_factor|twofa_|captcha_bypass|brute_force|default_credentials|weak_password|weak_jwt_secret|login_sqli_bypass|timing_side_channel_auth|timing_attack|refresh_token_abuse|auth_bypass|password_reset_poisoning)/, 'Auth & Session'],
|
|
[/^(idor|bola|bfla|access_control_bypass|privilege_escalation|forced_browsing|authenticated_surface_exploit|exposed_admin_panel|spa_hidden_admin|mass_assignment|register_privilege_mass_assign|api_excessive_data|excessive_data_exposure|account_takeover_chain)/, 'Broken Access Control'],
|
|
[/^(business_logic|spa_business_logic|coupon_logic_abuse|price_manipulation|workflow_step_skip|race_condition|idempotency_key_abuse|account_registration_and_forms)/, 'Business Logic'],
|
|
[/^(sqli_|nosql_injection|ldap_injection|xpath_injection|xslt_injection|ssti|command_injection|log_injection|crlf_injection|header_injection|email_injection|smtp_injection|soap_injection|orm_injection|expression_language_injection|graphql_injection|css_injection|html_injection|csv_injection|formula_injection_excel|dangling_markup_injection|client_side_template_injection|server_side_prototype_pollution|prototype_pollution|xxe|path_traversal|^lfi$|^rfi$|zip_slip|log4shell_jndi|pickle_deserialization|insecure_deserialization|yaml_deserialization|type_juggling)/, 'Injection'],
|
|
[/^(ssrf|gcp_metadata_ssrf|azure_imds_exposure|aws_imds_v2_bypass|host_header_injection|http_smuggling|http_desync|http2_request_smuggling|h2c_smuggling|reverse_proxy_path_confusion|websocket_)/, 'SSRF & Network'],
|
|
[/^(graphql_|api_rate_limiting|rest_api_versioning|api_key_exposure|exposed_api_docs|spa_api_discovery|param_miner|parameter_pollution|grpc_reflection_exposure|api_bola)/, 'API & GraphQL'],
|
|
[/^(aws_|azure_|gcp_|s3_bucket|gcs_bucket_misconfig|k8s_|docker_socket_exposure|container_escape|cloud_|terraform_state_exposure|helm_secret_exposure|ecr_public_exposure|serverless_|ci_cd_secret_leak|ad_)/, 'Cloud & Infra'],
|
|
[/^(clickjacking|tabnabbing|cors_misconfig|insecure_cookie_flags|security_headers|subdomain_takeover|open_redirect|second_order_redirect|oauth_open_redirect_chain|csrf)/, 'Client-Side'],
|
|
[/^(weak_encryption|weak_hashing|weak_random|padding_oracle|ecb_pattern_leak|ssl_issues|cleartext_transmission)/, 'Cryptography'],
|
|
[/^(rate_limit|graphql_dos|regex_dos|range_header_dos|web_cache_poisoning_dos|llm_model_dos)/, 'Rate Limiting & DoS'],
|
|
[/^(cache_poisoning|cdn_cache_key_poisoning|web_cache_deception|insecure_cdn|byte_range_cache|edge_side_includes)/, 'Cache & CDN'],
|
|
[/^(cve_|eol_|version_disclosure|wordpress_audit|joomla_audit|drupal_audit|cms_|outdated_|dependency_confusion|typosquatting_package|vulnerable_dependency|git_exposed_repo|git_svn_exposure_app|source_code_disclosure|backup_file_exposure|env_file_exposure|debug_mode|aspnet_|appserver_exposure|misconfig_|iis_|information_disclosure|sensitive_data_exposure|directory_listing)/, 'Recon & Fingerprint'],
|
|
[/^linux_/, 'Linux Host'],
|
|
[/^windows_/, 'Windows Host'],
|
|
];
|
|
|
|
function classify(name, kind) {
|
|
if (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 [];
|
|
}
|
|
const runs = await Promise.all(ids.map(async (id) => {
|
|
const dir = path.join(RUNS_DIR, id);
|
|
const [meta, status, findings] = await Promise.all([
|
|
readJsonSafe(path.join(dir, 'meta.json'), {}),
|
|
readJsonSafe(path.join(dir, 'status.json'), {}),
|
|
readJsonSafe(path.join(dir, 'findings.json'), []),
|
|
]);
|
|
const tsMatch = id.match(/^ns-(\d+)-/);
|
|
const ts = tsMatch ? Number(tsMatch[1]) : 0;
|
|
const sevCount = {};
|
|
for (const f of findings) sevCount[f.severity] = (sevCount[f.severity] || 0) + 1;
|
|
return {
|
|
id,
|
|
ts,
|
|
name: engagementNames.get(id) || '',
|
|
target: status.target || meta.target || id.replace(/^ns-\d+-/, ''),
|
|
state: status.state || 'unknown',
|
|
findings: findings.length,
|
|
severities: sevCount,
|
|
hasReport: fs.existsSync(path.join(dir, 'report.html')) || fs.existsSync(path.join(dir, 'report.pdf')),
|
|
};
|
|
}));
|
|
runs.sort((a, b) => b.ts - a.ts);
|
|
return runs;
|
|
}
|
|
|
|
async function runDetail(id) {
|
|
const dir = safeRunDir(id);
|
|
if (!dir) return null;
|
|
const [meta, status, findings] = await Promise.all([
|
|
readJsonSafe(path.join(dir, 'meta.json'), {}),
|
|
readJsonSafe(path.join(dir, 'status.json'), {}),
|
|
readJsonSafe(path.join(dir, 'findings.json'), []),
|
|
]);
|
|
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
|
.filter((f) => fs.existsSync(path.join(dir, f)));
|
|
const pocs = await fsp.readdir(path.join(dir, 'pocs')).catch(() => []);
|
|
return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs };
|
|
}
|
|
|
|
function safeRunDir(id) {
|
|
if (!/^[a-zA-Z0-9_.-]+$/.test(id)) return null;
|
|
const dir = path.join(RUNS_DIR, id);
|
|
if (!dir.startsWith(RUNS_DIR)) return null;
|
|
return dir;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exploitation jobs — spawn `neurosploit <mode> <target> --only ... -v`
|
|
// and parse its stdout into structured live state (mirrors app/src/repl.rs
|
|
// RunLive::ingest so the web UI gets the same phases/findings the TUI does).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const jobs = new Map(); // id -> Job
|
|
|
|
class Job extends EventEmitter {
|
|
constructor(id, cmd, args, target, name) {
|
|
super();
|
|
this.id = id;
|
|
this.cmd = cmd;
|
|
this.args = args;
|
|
this.target = target || '';
|
|
this.name = name || '';
|
|
this.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);
|
|
}
|
|
snapshot() {
|
|
return {
|
|
id: this.id,
|
|
target: this.target,
|
|
name: this.name,
|
|
pinnedAgents: this.pinnedAgents,
|
|
runId: this.runId,
|
|
phase: this.phase,
|
|
findings: this.findings,
|
|
agents: this.agents,
|
|
agentsDone: this.agentsDone,
|
|
done: this.done,
|
|
exitCode: this.exitCode,
|
|
reportUrl: this.reportUrl,
|
|
startedAt: this.startedAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
function stripAnsi(s) { return s.replace(ANSI_RE, ''); }
|
|
|
|
function ingestLine(job, rawLine) {
|
|
const line = stripAnsi(rawLine);
|
|
const low = line.toLowerCase();
|
|
job.push({ type: 'log', line });
|
|
|
|
if (low.includes('token/quota exhausted') || low.includes('run is paused')) job.phase = 'paused (quota)';
|
|
else if (low.includes('authentication failed') || low.includes('circuit breaker')) job.phase = 'paused (auth)';
|
|
else if (low.startsWith('recon') || low.startsWith('ai-recon') || low.includes('recon round') || low.startsWith('probe:')) job.phase = 'recon';
|
|
else if (low.includes('selected') && low.includes('agent')) {
|
|
job.phase = 'planning';
|
|
const n = line.split(/\s+/).map(Number).find((x) => Number.isFinite(x));
|
|
if (n) job.agents = n;
|
|
} else if (low.startsWith('exploit') || low.startsWith('test ') || low.includes('launching agent')) job.phase = 'exploiting';
|
|
else if (low.startsWith('vote') || low.includes('validating')) job.phase = 'validating';
|
|
else if (low.startsWith('chain')) job.phase = 'chaining';
|
|
else if (low.includes('phase complete') || low.includes('validated finding(s)')) job.phase = 'complete';
|
|
|
|
if (/candidate\(s\)/.test(low) && /^(exploit |test |analyze |review )/.test(low)) job.agentsDone += 1;
|
|
|
|
const fj = line.match(/^finding_json:\s*(.+)$/);
|
|
if (fj) {
|
|
try {
|
|
const finding = JSON.parse(fj[1]);
|
|
job.findings.push(finding);
|
|
job.push({ type: 'finding', finding });
|
|
} catch { /* ignore malformed line */ }
|
|
}
|
|
|
|
const rep = line.match(/report:\s*(file:\/\/\S+)/);
|
|
if (rep) job.reportUrl = rep[1];
|
|
|
|
const rid = line.match(/run id\s*:\s*(\S+)/);
|
|
if (rid) { job.runId = rid[1]; saveEngagementName(job.runId, job.name); }
|
|
}
|
|
|
|
function buildArgs(body) {
|
|
const mode = body.mode || 'run';
|
|
const args = [mode];
|
|
if (mode === 'run' || mode === 'host' || mode === 'aitest') {
|
|
args.push(body.target);
|
|
} else if (mode === 'whitebox' || mode === 'skills') {
|
|
args.push(body.repo || body.target);
|
|
} else if (mode === 'greybox') {
|
|
args.push(body.repo);
|
|
args.push('--url', body.target);
|
|
}
|
|
for (const m of body.models || []) args.push('--model', m);
|
|
if (body.votes) args.push('--vote-n', String(body.votes));
|
|
if (body.chainDepth !== undefined) args.push('--chain-depth', String(body.chainDepth));
|
|
if (body.recon) args.push('--recon', String(body.recon));
|
|
if (body.maxAgents) args.push('--max-agents', String(body.maxAgents));
|
|
if (body.offline) args.push('--offline');
|
|
if (body.subscription) args.push('--subscription');
|
|
if (body.mcp) args.push('--mcp');
|
|
if (body.creds) args.push('--creds', body.creds);
|
|
if (body.focus) args.push('--focus', body.focus);
|
|
if (body.objective) args.push('--objective', body.objective);
|
|
if (body.outOfScope) args.push('--out-of-scope', body.outOfScope);
|
|
for (const a of body.agents || []) args.push('--only', a);
|
|
args.push('--verbose');
|
|
return args;
|
|
}
|
|
|
|
async function startJob(body) {
|
|
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
|
const id = crypto.randomUUID();
|
|
const credsPath = await materializeCreds(body, id);
|
|
const args = buildArgs({ ...body, creds: credsPath });
|
|
const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || '');
|
|
job.pinnedAgents = body.agents || [];
|
|
jobs.set(id, job);
|
|
|
|
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
|
job.child = child;
|
|
let buf = '';
|
|
const onData = (chunk) => {
|
|
buf += chunk.toString('utf8');
|
|
let idx;
|
|
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
const line = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 1);
|
|
if (line.length) ingestLine(job, line);
|
|
}
|
|
};
|
|
child.stdout.on('data', onData);
|
|
child.stderr.on('data', onData);
|
|
child.on('close', (code) => {
|
|
if (buf.trim()) ingestLine(job, buf);
|
|
job.done = true;
|
|
job.exitCode = code;
|
|
job.phase = job.phase === 'paused (quota)' || job.phase === 'paused (auth)' ? job.phase : 'complete';
|
|
job.push({ type: 'done', exitCode: code });
|
|
});
|
|
child.on('error', (err) => {
|
|
job.done = true;
|
|
job.push({ type: 'log', line: `[web] failed to start neurosploit: ${err.message}` });
|
|
job.push({ type: 'done', exitCode: -1 });
|
|
});
|
|
return job;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REPL sessions — spawn `neurosploit` with no subcommand (Reader::Plain kicks
|
|
// in over a piped stdin) and forward stdin/stdout verbatim: a real REPL.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const replSessions = new Map();
|
|
|
|
class ReplSession extends EventEmitter {
|
|
constructor(id, child) {
|
|
super();
|
|
this.id = id;
|
|
this.child = child;
|
|
this.done = false;
|
|
this.buffer = [];
|
|
}
|
|
push(chunk) {
|
|
this.buffer.push(chunk);
|
|
if (this.buffer.length > 5000) this.buffer.shift();
|
|
this.emit('data', chunk);
|
|
}
|
|
}
|
|
|
|
function startRepl() {
|
|
if (!BIN) throw new Error('neurosploit binary not found — run `cargo build --release` in neurosploit-rs/');
|
|
const id = crypto.randomUUID();
|
|
const child = spawn(BIN, [], { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
|
const session = new ReplSession(id, child);
|
|
replSessions.set(id, session);
|
|
const onData = (chunk) => session.push(stripAnsi(chunk.toString('utf8')));
|
|
child.stdout.on('data', onData);
|
|
child.stderr.on('data', onData);
|
|
child.on('close', (code) => {
|
|
session.done = true;
|
|
session.push(`\n[repl session ended, exit code ${code}]\n`);
|
|
session.emit('close');
|
|
});
|
|
child.on('error', (err) => {
|
|
session.done = true;
|
|
session.push(`\n[failed to start neurosploit: ${err.message}]\n`);
|
|
session.emit('close');
|
|
});
|
|
return session;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tiny HTTP plumbing (no framework)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function sendJson(res, code, obj) {
|
|
const body = JSON.stringify(obj);
|
|
res.writeHead(code, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
req.on('data', (c) => { data += c; if (data.length > 5_000_000) req.destroy(); });
|
|
req.on('end', () => {
|
|
if (!data) return resolve({});
|
|
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function sseInit(res) {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
res.write(':ok\n\n');
|
|
}
|
|
function sseSend(res, event, data) {
|
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
}
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.pdf': 'application/pdf',
|
|
'.md': 'text/plain; charset=utf-8',
|
|
};
|
|
|
|
async function serveStatic(req, res, urlPath) {
|
|
let rel = urlPath === '/' ? '/index.html' : urlPath;
|
|
const full = path.join(PUBLIC_DIR, rel);
|
|
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end(); return; }
|
|
try {
|
|
const data = await fsp.readFile(full);
|
|
const ext = path.extname(full);
|
|
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
res.end(data);
|
|
} catch {
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
}
|
|
}
|
|
|
|
async function serveRunAsset(req, res, id, rest) {
|
|
const dir = safeRunDir(id);
|
|
if (!dir) { res.writeHead(400); res.end(); return; }
|
|
const full = path.join(dir, rest);
|
|
if (!full.startsWith(dir)) { res.writeHead(403); res.end(); return; }
|
|
try {
|
|
const data = await fsp.readFile(full);
|
|
const ext = path.extname(full);
|
|
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
res.end(data);
|
|
} catch {
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
}
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const u = new URL(req.url, 'http://localhost');
|
|
const p = u.pathname;
|
|
|
|
try {
|
|
// ---- static ----
|
|
if (req.method === 'GET' && !p.startsWith('/api/')) {
|
|
return serveStatic(req, res, p);
|
|
}
|
|
|
|
// ---- agents / lead board ----
|
|
if (req.method === 'GET' && p === '/api/agents') {
|
|
return sendJson(res, 200, await loadAgents());
|
|
}
|
|
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 });
|
|
}
|
|
|
|
// ---- runs ----
|
|
if (req.method === 'GET' && p === '/api/runs') {
|
|
return sendJson(res, 200, await listRuns());
|
|
}
|
|
let m = p.match(/^\/api\/runs\/([^/]+)$/);
|
|
if (req.method === 'GET' && m) {
|
|
const detail = await runDetail(decodeURIComponent(m[1]));
|
|
if (!detail) return sendJson(res, 404, { error: 'run not found' });
|
|
return sendJson(res, 200, detail);
|
|
}
|
|
m = p.match(/^\/api\/runs\/([^/]+)\/asset\/(.+)$/);
|
|
if (req.method === 'GET' && m) {
|
|
return serveRunAsset(req, res, decodeURIComponent(m[1]), decodeURIComponent(m[2]));
|
|
}
|
|
|
|
// ---- exploitation jobs ----
|
|
if (req.method === 'GET' && p === '/api/exploit') {
|
|
return sendJson(res, 200, [...jobs.values()].map((j) => j.snapshot()));
|
|
}
|
|
if (req.method === 'POST' && p === '/api/exploit') {
|
|
const body = await readBody(req);
|
|
const job = await startJob(body);
|
|
return sendJson(res, 200, { id: job.id });
|
|
}
|
|
m = p.match(/^\/api\/exploit\/([^/]+)$/);
|
|
if (req.method === 'GET' && m) {
|
|
const job = jobs.get(m[1]);
|
|
if (!job) return sendJson(res, 404, { error: 'job not found' });
|
|
return sendJson(res, 200, job.snapshot());
|
|
}
|
|
m = p.match(/^\/api\/exploit\/([^/]+)\/stop$/);
|
|
if (req.method === 'POST' && m) {
|
|
const job = jobs.get(m[1]);
|
|
if (!job) return sendJson(res, 404, { error: 'job not found' });
|
|
job.child?.kill('SIGINT');
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
m = p.match(/^\/api\/exploit\/([^/]+)\/events$/);
|
|
if (req.method === 'GET' && m) {
|
|
const job = jobs.get(m[1]);
|
|
if (!job) { res.writeHead(404); return res.end(); }
|
|
sseInit(res);
|
|
// replay what already happened
|
|
for (const evt of job.feed) sseSend(res, evt.type, evt);
|
|
sseSend(res, 'snapshot', job.snapshot());
|
|
if (job.done) { sseSend(res, 'done', job.snapshot()); res.end(); return; }
|
|
const onEvt = (evt) => sseSend(res, evt.type, evt);
|
|
job.on('event', onEvt);
|
|
const ping = setInterval(() => res.write(':ping\n\n'), 20000);
|
|
req.on('close', () => { job.off('event', onEvt); clearInterval(ping); });
|
|
return;
|
|
}
|
|
|
|
// ---- REPL (real CLI harness session) ----
|
|
if (req.method === 'POST' && p === '/api/repl') {
|
|
const session = startRepl();
|
|
return sendJson(res, 200, { id: session.id });
|
|
}
|
|
m = p.match(/^\/api\/repl\/([^/]+)\/input$/);
|
|
if (req.method === 'POST' && m) {
|
|
const session = replSessions.get(m[1]);
|
|
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
const body = await readBody(req);
|
|
session.child.stdin.write(String(body.line ?? '') + '\n');
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
m = p.match(/^\/api\/repl\/([^/]+)\/stop$/);
|
|
if (req.method === 'POST' && m) {
|
|
const session = replSessions.get(m[1]);
|
|
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
session.child.kill('SIGTERM');
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
m = p.match(/^\/api\/repl\/([^/]+)\/events$/);
|
|
if (req.method === 'GET' && m) {
|
|
const session = replSessions.get(m[1]);
|
|
if (!session) { res.writeHead(404); return res.end(); }
|
|
sseInit(res);
|
|
for (const chunk of session.buffer) sseSend(res, 'data', { chunk });
|
|
if (session.done) { sseSend(res, 'close', {}); res.end(); return; }
|
|
const onData = (chunk) => sseSend(res, 'data', { chunk });
|
|
const onClose = () => { sseSend(res, 'close', {}); res.end(); };
|
|
session.on('data', onData);
|
|
session.on('close', onClose);
|
|
const ping = setInterval(() => res.write(':ping\n\n'), 20000);
|
|
req.on('close', () => { session.off('data', onData); session.off('close', onClose); clearInterval(ping); });
|
|
return;
|
|
}
|
|
|
|
if (req.method === 'GET' && p === '/api/meta') {
|
|
return sendJson(res, 200, { version: '4.0.0', binary: BIN, root: ROOT });
|
|
}
|
|
|
|
// ---- providers / API keys (in-memory only, never persisted) ----
|
|
if (req.method === 'GET' && p === '/api/providers') {
|
|
return sendJson(res, 200, PROVIDERS.map(({ key, label, kind, models }) => ({ key, label, kind, models })));
|
|
}
|
|
if (req.method === 'GET' && p === '/api/keys') {
|
|
return sendJson(res, 200, PROVIDERS.map((pr) => ({ provider: pr.key, set: apiKeys.has(pr.key) && !!apiKeys.get(pr.key) })));
|
|
}
|
|
if (req.method === 'POST' && p === '/api/keys') {
|
|
const body = await readBody(req);
|
|
if (!body.provider || !PROVIDERS.some((pr) => pr.key === body.provider)) {
|
|
return sendJson(res, 400, { error: 'unknown provider' });
|
|
}
|
|
if (body.key) apiKeys.set(body.provider, body.key);
|
|
else apiKeys.delete(body.provider);
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
m = p.match(/^\/api\/keys\/([^/]+)$/);
|
|
if (req.method === 'DELETE' && m) {
|
|
apiKeys.delete(decodeURIComponent(m[1]));
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
|
|
sendJson(res, 404, { error: 'not found' });
|
|
} catch (err) {
|
|
sendJson(res, 500, { error: err.message });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`NeuroSploit v4.0.0 web console → http://localhost:${PORT}`);
|
|
console.log(` binary : ${BIN || '(not found — build neurosploit-rs first)'}`);
|
|
console.log(` agents : ${AGENTS_DIR}`);
|
|
console.log(` runs : ${RUNS_DIR}`);
|
|
});
|