From e1350c0c4a60cde9a9ec612fc2ed58602cb3b55e Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Fri, 2 Oct 2026 19:56:37 -0300 Subject: [PATCH] fix(web): REPL spawn passed run-only flags as globals, breaking every run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL-backed path (run/whitebox/greybox) spawns `neurosploit` with NO subcommand, so only global flags are valid in argv — but authArgs() emitted run-subcommand flags there (--environment, --policy, --in-scope, --budget, --compliance, --revalidate-poc, --token-limit, --deep-test-limit, --order, --sample-per-route, --scope-file). clap aborted on the first one ("unexpected argument '--environment'"), so the engagement died at launch and the live view sat empty. authArgs now emits only the real global flags with their global names (--session-environment / --session-in-scope / --session-policy, plus --capability-token/--transport/--oob-*/--sms/--typesafe/--decision-backend/ --intercept/--sandbox); run-only knobs ride the REPL script or defaults. Also: - sidebar: a disk run whose status says "running" but has no live job is shown as "interrupted", not "running" (no more stale RUNNING entries); brand-new in-memory jobs are injected so an engagement appears the moment it starts; new "Interrupted" group; clicking a running/interrupted row attaches the live stream or offers resume. - stop: robust now — graceful /stop then SIGTERM/SIGKILL fallback, a second press escalates, a job whose child already exited is marked done so the UI stops showing it as running. Co-Authored-By: Claude Opus 4.8 --- web/public/app.js | 50 +++++++++++++++++++++++++++- web/server.js | 84 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 20 deletions(-) diff --git a/web/public/app.js b/web/public/app.js index 32778e4..85d3597 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -1625,7 +1625,8 @@ function renderSidebar() { const match = (r) => !q || `${r.name} ${r.target} ${r.id}`.toLowerCase().includes(q); const runs = state.runs.filter(match); const running = runs.filter((r) => r.state === 'running'); - const past = runs.filter((r) => r.state !== 'running'); + const interrupted = runs.filter((r) => r.state === 'interrupted'); + const past = runs.filter((r) => r.state !== 'running' && r.state !== 'interrupted'); if (running.length) { const wrap = document.createElement('div'); @@ -1646,6 +1647,16 @@ function renderSidebar() { root.appendChild(wrap); } + if (interrupted.length) { + const wrap = document.createElement('div'); + wrap.className = 'sb-group'; + wrap.innerHTML = `
▾Interrupted${interrupted.length}
`; + wrap.querySelector('.sb-group-head').addEventListener('click', () => wrap.classList.toggle('collapsed')); + const items = wrap.querySelector('.sb-items'); + for (const r of interrupted) items.appendChild(runButton(r)); + root.appendChild(wrap); + } + const folders = runFolders(past); if (!folders.length) { const empty = document.createElement('div'); @@ -1682,6 +1693,29 @@ function renderSidebar() { } } +// Resume an interrupted run from the sidebar: if a persisted job exists for it, +// relaunch it live; otherwise just open its detail (partial findings on disk). +async function resumeFromSidebar(run) { + let jobs = []; + try { jobs = await api('/api/exploit'); } catch { /* fall through to detail */ } + const job = jobs.find((j) => (j.interrupted && j.resumable) && (j.runId === run.id || j.id === run.id || j.id === run.jobId)); + if (job) { + try { + await api(`/api/exploit/${job.id}/resume`, { method: 'POST' }); + localStorage.setItem(ACTIVE_JOB_KEY, job.id); + attachLiveJob(job.id, run.target, run.name, job.pinnedAgents || []); + await refreshRuns(); + return; + } catch (e) { + toast(`Couldn't resume: ${e.message}`, 'error', 8000); + } + } + // No resumable job — show what's on disk. + show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true); + loadDetail(run.id); + renderSidebar(); +} + function openRun(run) { state.currentDetailId = run.id; if (run.state === 'running' && state.currentJob && run.id === state.currentJob.runId) { @@ -1689,6 +1723,20 @@ function openRun(run) { renderSidebar(); return; } + // A running job we're not already attached to (e.g. opened in another tab, or + // just started): reconnect its live stream instead of showing a static detail. + if (run.state === 'running' && (run.jobId || run.id)) { + const jid = run.jobId || run.id; + localStorage.setItem(ACTIVE_JOB_KEY, jid); + attachLiveJob(jid, run.target, run.name, []); + renderSidebar(); + return; + } + if (run.state === 'interrupted') { + // Offer to resume where it left off; falls back to the static detail view. + resumeFromSidebar(run); + return; + } show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true); loadDetail(run.id); renderSidebar(); diff --git a/web/server.js b/web/server.js index a6c64f9..cfbd048 100644 --- a/web/server.js +++ b/web/server.js @@ -434,6 +434,13 @@ async function listRuns() { } 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([ @@ -445,17 +452,40 @@ async function listRuns() { 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: status.state || 'unknown', + 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; } @@ -772,13 +802,18 @@ async function startJob(body) { /// 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 = []; - 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 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); @@ -787,16 +822,9 @@ function authArgs(body) { 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); - if (body.revalidatePoc) args.push('--revalidate-poc'); - for (const fw of body.compliance || []) args.push('--compliance', fw); - 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)); return args; } @@ -1255,13 +1283,31 @@ const server = http.createServer(async (req, res) => { 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) { - // 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'); + 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 { - job.child?.kill('SIGINT'); + 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 }); }