mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-25 10:52:31 +02:00
feat(web): drive run/whitebox/greybox exploitation through a real REPL session
Root cause of "can't send prompts while a run streams": /api/exploit spawned a plain `neurosploit run ...` subprocess, and that CLI path (run_mode() in main.rs) never reads stdin - it only waits on the task or Ctrl-C. The ONLY thing in the harness that keeps accepting input while an engagement streams is the interactive REPL's background-run loop. So: - New startJobViaRepl(): for mode run/whitebox/greybox, spawns a bare `neurosploit` REPL session and scripts it via stdin (/target or /repo, /model, /sub, /mcp, /votes, /chain, /recon, /focus, /objective, /scope-out, /creds, /only <agents> or /only clear, then /run) instead of building CLI args. Same underlying pipeline, same tagged output lines, so all existing parsing (findings/phase/progress/runId) works unchanged. host/aitest/skills modes stay on the old one-shot startJob() - they need onboarding's scope picker, an interactive arrow-key menu that silently skips itself over a piped stdin, so they can't be scripted this way. - New POST /api/exploit/:id/input writes a line to the session's stdin - natural language, /status, /continue, anything the REPL accepts - and the live run view grows a "send prompt" box (in the Activity log tab) for it, shown only when the job reports interactive: true. - Stop, for an interactive job, now sends the REPL's own graceful '/stop\n1\n' (validate what's found, then report) instead of SIGINT - the REPL's own input loop has no signal handler, so SIGINT there would just kill the process outright and skip the report step. Non- interactive jobs still get SIGINT (run_mode() does catch that). - 'done' can no longer be process-exit only: an interactive session stays open after the engagement finishes (for /report, /continue, another /run), so ingestLine() now also flags done from the same "phase complete" content signal it already used for the phase field. Verified end-to-end: started an interactive job, confirmed `interactive: true` and a captured runId, sent /status and /agents mid- and post-run over the new /input endpoint (both accepted, session stayed alive and responsive after completion), and confirmed a non-interactive run is unaffected. Also: the missing "Activity log" tab a screenshot showed for a "running" engagement was the sidebar's detail-view fallback (2 tabs, no log) for a run whose Job object no longer exists in server memory - it happens when the Node process gets restarted while a spawned neurosploit child is still alive underneath it (an orphan from testing across many redeploys this session, not a code bug); the live view itself always had the tab. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
07bed42467
commit
4fbe608a7a
@@ -443,6 +443,8 @@ function attachLiveJob(id, target, name, pinnedAgents) {
|
|||||||
$('#progressLabel').textContent = '0 / ? agents';
|
$('#progressLabel').textContent = '0 / ? agents';
|
||||||
updatePinnedLine();
|
updatePinnedLine();
|
||||||
show($('#btnOpenReport'), false);
|
show($('#btnOpenReport'), false);
|
||||||
|
show($('#sendPromptRow'), false);
|
||||||
|
show($('#sendPromptHelp'), false);
|
||||||
|
|
||||||
const es = new EventSource(`/api/exploit/${id}/events`);
|
const es = new EventSource(`/api/exploit/${id}/events`);
|
||||||
state.currentJob.es = es;
|
state.currentJob.es = es;
|
||||||
@@ -502,6 +504,29 @@ function appendLog(line) {
|
|||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only run/whitebox/greybox jobs are REPL-backed (interactive: true) — the
|
||||||
|
// session keeps reading stdin while the engagement streams, so this is a
|
||||||
|
// real command line into the SAME process, not a fire-and-forget note.
|
||||||
|
$('#sendPromptInput').addEventListener('keydown', async (e) => {
|
||||||
|
if (e.key !== 'Enter' || !state.currentJob) return;
|
||||||
|
const line = e.target.value;
|
||||||
|
if (!line.trim()) return;
|
||||||
|
e.target.value = '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'log-line log-echo';
|
||||||
|
div.textContent = `❭ ${line}`;
|
||||||
|
const list = $('#logList');
|
||||||
|
list.appendChild(div);
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
try {
|
||||||
|
await api(`/api/exploit/${state.currentJob.id}/input`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ line }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
appendLog(`[web] couldn't send: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function findingRow(f, idx) {
|
function findingRow(f, idx) {
|
||||||
return `<tr data-idx="${idx}">
|
return `<tr data-idx="${idx}">
|
||||||
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
|
||||||
@@ -538,6 +563,9 @@ function addFinding(f) {
|
|||||||
function applySnapshot(snap) {
|
function applySnapshot(snap) {
|
||||||
$('#livePhase').textContent = snap.phase;
|
$('#livePhase').textContent = snap.phase;
|
||||||
state.currentJob.runId = snap.runId;
|
state.currentJob.runId = snap.runId;
|
||||||
|
state.currentJob.interactive = !!snap.interactive;
|
||||||
|
show($('#sendPromptRow'), snap.interactive && !snap.done);
|
||||||
|
show($('#sendPromptHelp'), snap.interactive && !snap.done);
|
||||||
if (snap.pinnedAgents?.length && !state.currentJob.pinnedAgents.length) {
|
if (snap.pinnedAgents?.length && !state.currentJob.pinnedAgents.length) {
|
||||||
state.currentJob.pinnedAgents = snap.pinnedAgents;
|
state.currentJob.pinnedAgents = snap.pinnedAgents;
|
||||||
updatePinnedLine();
|
updatePinnedLine();
|
||||||
|
|||||||
@@ -227,7 +227,14 @@
|
|||||||
<div class="run-body">
|
<div class="run-body">
|
||||||
<div class="run-tab-panel" data-tabpanel="findings"><table class="data-table" id="liveFindingsTable"><thead><tr><th>Severity</th><th>Title</th><th>Endpoint</th><th>CWE</th><th>Agent</th><th>Conf.</th></tr></thead><tbody></tbody></table><div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div></div>
|
<div class="run-tab-panel" data-tabpanel="findings"><table class="data-table" id="liveFindingsTable"><thead><tr><th>Severity</th><th>Title</th><th>Endpoint</th><th>CWE</th><th>Agent</th><th>Conf.</th></tr></thead><tbody></tbody></table><div class="empty-state" id="liveFindingsEmpty">No validated findings yet.</div></div>
|
||||||
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="liveAttackPath"></div></div>
|
<div class="run-tab-panel" data-tabpanel="attackpath" hidden><div id="liveAttackPath"></div></div>
|
||||||
<div class="run-tab-panel" data-tabpanel="log" hidden><div class="log-panel" id="logList" style="height: 100%;"></div></div>
|
<div class="run-tab-panel log-tab-panel" data-tabpanel="log" hidden>
|
||||||
|
<div class="log-panel" id="logList"></div>
|
||||||
|
<div class="send-prompt-row" id="sendPromptRow" hidden>
|
||||||
|
<span class="repl-prompt">❭</span>
|
||||||
|
<input id="sendPromptInput" type="text" autocomplete="off" spellcheck="false" placeholder="/status · /stop · /continue · or describe it in plain language" />
|
||||||
|
</div>
|
||||||
|
<div class="field-help" id="sendPromptHelp" hidden>This session stays interactive while the engagement runs — type a command or plain instruction and press Enter.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,12 @@ textarea { resize: vertical; min-height: 72px; }
|
|||||||
|
|
||||||
.log-panel { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); max-height: 100%; overflow-y: auto; padding: var(--sp-3); }
|
.log-panel { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); max-height: 100%; overflow-y: auto; padding: var(--sp-3); }
|
||||||
.log-line { font-family: var(--mono); font-size: 11px; color: var(--text-dim); padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
|
.log-line { font-family: var(--mono); font-size: 11px; color: var(--text-dim); padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.log-tab-panel { display: flex; flex-direction: column; height: 100%; gap: var(--sp-2); }
|
||||||
|
.log-tab-panel .log-panel { flex: 1; }
|
||||||
|
.send-prompt-row { display: flex; align-items: center; gap: var(--sp-2); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: var(--sp-2) var(--sp-3); background: var(--surface); }
|
||||||
|
.send-prompt-row .repl-prompt { color: var(--accent); font-family: var(--mono); }
|
||||||
|
#sendPromptInput { flex: 1; border: none; background: transparent; font-family: var(--mono); font-size: 12.5px; outline: none; color: var(--text); }
|
||||||
|
.log-echo { color: var(--accent); }
|
||||||
|
|
||||||
/* ============================================================ Modal (Auth & Keys) */
|
/* ============================================================ Modal (Auth & Keys) */
|
||||||
|
|
||||||
|
|||||||
+108
-4
@@ -451,6 +451,7 @@ class Job extends EventEmitter {
|
|||||||
target: this.target,
|
target: this.target,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
pinnedAgents: this.pinnedAgents,
|
pinnedAgents: this.pinnedAgents,
|
||||||
|
interactive: !!this.repl,
|
||||||
runId: this.runId,
|
runId: this.runId,
|
||||||
phase: this.phase,
|
phase: this.phase,
|
||||||
findings: this.findings,
|
findings: this.findings,
|
||||||
@@ -482,7 +483,13 @@ function ingestLine(job, rawLine) {
|
|||||||
} else if (low.startsWith('exploit') || low.startsWith('test ') || low.includes('launching agent')) job.phase = 'exploiting';
|
} 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('vote') || low.includes('validating')) job.phase = 'validating';
|
||||||
else if (low.startsWith('chain')) job.phase = 'chaining';
|
else if (low.startsWith('chain')) job.phase = 'chaining';
|
||||||
else if (low.includes('phase complete') || low.includes('validated finding(s)')) job.phase = 'complete';
|
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(low) && /^(exploit |test |analyze |review )/.test(low)) job.agentsDone += 1;
|
if (/candidate\(s\)/.test(low) && /^(exploit |test |analyze |review )/.test(low)) job.agentsDone += 1;
|
||||||
|
|
||||||
@@ -568,6 +575,80 @@ async function startJob(body) {
|
|||||||
return job;
|
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).
|
||||||
|
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.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}`);
|
||||||
|
lines.push((body.agents || []).length ? `/only ${body.agents.join(',')}` : '/only clear');
|
||||||
|
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 script = buildReplScript({ ...body, creds: credsPath });
|
||||||
|
const job = new Job(id, BIN, [], body.repo || body.target || '', body.name || '');
|
||||||
|
job.pinnedAgents = body.agents || [];
|
||||||
|
job.repl = true;
|
||||||
|
jobs.set(id, job);
|
||||||
|
|
||||||
|
const child = spawn(BIN, [], { 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;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// REPL sessions — spawn `neurosploit` with no subcommand (Reader::Plain kicks
|
// REPL sessions — spawn `neurosploit` with no subcommand (Reader::Plain kicks
|
||||||
// in over a piped stdin) and forward stdin/stdout verbatim: a real REPL.
|
// in over a piped stdin) and forward stdin/stdout verbatim: a real REPL.
|
||||||
@@ -758,8 +839,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (req.method === 'POST' && p === '/api/exploit') {
|
if (req.method === 'POST' && p === '/api/exploit') {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const job = await startJob(body);
|
// run/whitebox/greybox go through a real REPL session so the operator
|
||||||
return sendJson(res, 200, { id: job.id });
|
// 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\/([^/]+)$/);
|
m = p.match(/^\/api\/exploit\/([^/]+)$/);
|
||||||
if (req.method === 'GET' && m) {
|
if (req.method === 'GET' && m) {
|
||||||
@@ -771,7 +857,25 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === 'POST' && m) {
|
if (req.method === 'POST' && m) {
|
||||||
const job = jobs.get(m[1]);
|
const job = jobs.get(m[1]);
|
||||||
if (!job) return sendJson(res, 404, { error: 'job not found' });
|
if (!job) return sendJson(res, 404, { error: 'job not found' });
|
||||||
job.child?.kill('SIGINT');
|
if (job.repl && job.child?.stdin?.writable) {
|
||||||
|
// The REPL's own graceful stop: /stop then choose "1" — validate
|
||||||
|
// what's found so far, then report. Plain SIGINT doesn't map to
|
||||||
|
// anything here (no signal handler in the REPL's own input loop).
|
||||||
|
job.child.stdin.write('/stop\n1\n');
|
||||||
|
} else {
|
||||||
|
job.child?.kill('SIGINT');
|
||||||
|
}
|
||||||
|
return sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
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 });
|
return sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
m = p.match(/^\/api\/exploit\/([^/]+)\/events$/);
|
m = p.match(/^\/api\/exploit\/([^/]+)\/events$/);
|
||||||
|
|||||||
Reference in New Issue
Block a user