From 3dcfeb7377181c257c4865fc8346db5c857a1c1e Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 23 Aug 2026 14:20:34 -0300 Subject: [PATCH] feat(web): require an engagement name before launch Wizard's Asset step now opens with a required 'Engagement name' field (validated before advancing or launching). The name isn't a harness/CLI concept, so it's persisted server-side as runId -> name in .neurosploit/web-engagement-names.json (keyed off the CLI's own run id, captured from its 'run id : ns-...' log line) so the sidebar, live run header, and run detail can label a run by name instead of the raw target/run-id, surviving a server restart. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd --- web/API.md | 11 +++++++++-- web/public/app.js | 24 +++++++++++++++++------- web/public/index.html | 7 +++++++ web/server.js | 30 ++++++++++++++++++++++++++---- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/web/API.md b/web/API.md index 6728c96..35acec1 100644 --- a/web/API.md +++ b/web/API.md @@ -94,14 +94,15 @@ Lists `runs/ns-*` directories, newest first, with a summary read from each run's `meta.json` / `status.json` / `findings.json`. ```json -[ { "id": "ns-1787504238-testphp_vulnweb_com", "ts": 1787504238, "target": "http://testphp.vulnweb.com/", "state": "running", "findings": 3, "severities": { "High": 1, "Medium": 2 }, "hasReport": false } ] +[ { "id": "ns-1787504238-testphp_vulnweb_com", "ts": 1787504238, "name": "Keystone – Digital Banking", "target": "http://testphp.vulnweb.com/", "state": "running", "findings": 3, "severities": { "High": 1, "Medium": 2 }, "hasReport": false } ] ``` `state` mirrors the CLI's `status.json`: `running` | `complete` | `stopped-raw` | `discarded` | `unknown`. ### `GET /api/runs/:id` -Full detail for one run: `{ id, meta, status, findings, assets }`. `findings` is the raw +Full detail for one run: `{ id, name, meta, status, findings, assets }` (`name` is the engagement +name set in the wizard, `""` if this run predates that or was started outside the web console). `findings` is the raw `findings.json` array (see [Finding shape](#finding-shape) below). `assets` lists which generated files exist (`report.html`, `report.pdf`, `report.md`, `recon.md`, `exploitation.md`). @@ -126,6 +127,7 @@ Body: ```jsonc { "mode": "run", // run | whitebox | greybox | host | aitest | skills + "name": "Keystone – Digital Banking", // engagement name — required by the wizard UI "target": "https://example.com", // required for run/host/aitest/greybox "repo": "owner/repo", // required for whitebox; source repo for greybox "models": ["anthropic:claude-opus-4-8"], // optional, repeatable in the CLI @@ -154,6 +156,11 @@ If `creds` is omitted and either `auth` or `roles` is set, the server writes a m path always wins over `auth`/`roles`. These ephemeral files are not cleaned up automatically — they live in the OS temp dir, never in the repo. +`name` is not a harness/CLI concept — the server persists a `runId -> name` map to +`.neurosploit/web-engagement-names.json` (keyed on the CLI's own run id, captured from its +"run id : ns-…" log line) so `/api/runs` and `/api/runs/:id` can label a run by its engagement +name, surviving a server restart. + Response: `{ "id": "" }`. This `id` is the **web job id**, not the run id — the CLI's own `ns--` run id is discovered from its own log line and exposed as `runId` in the job snapshot once the engagement starts writing to `runs/`. diff --git a/web/public/app.js b/web/public/app.js index ba9cf86..b1a378f 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -98,6 +98,7 @@ function goToStep(n) { function validateStep(n) { if (n === 0) { + if (!$('#fieldName').value.trim()) { alert('Name the engagement first — it identifies this run in the sidebar and history.'); $('#fieldName').focus(); return false; } const target = $('#fieldTarget').value.trim(); if (!target) { alert(`${MODE_LABELS[state.mode].target} is required.`); return false; } if (state.mode === 'greybox' && !$('#fieldRepo').value.trim()) { alert('Source repo is required for grey-box.'); return false; } @@ -113,9 +114,11 @@ $$('.step-tab').forEach((tab) => tab.addEventListener('click', () => { })); function updateWizardSummary() { + const name = $('#fieldName').value.trim() || '(unnamed)'; const target = $('#fieldTarget').value.trim() || '(not set)'; - $('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · ${esc(state.mode)} · ${esc(target)}`; + $('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · ${esc(name)} · ${esc(state.mode)} · ${esc(target)}`; } +$('#fieldName').addEventListener('input', updateWizardSummary); // mode tiles function selectMode(mode) { @@ -292,6 +295,7 @@ function renderReview() { const provider = $('#fieldProvider').value; const model = $('#fieldModelSelect').value; const items = [ + { k: 'Engagement name', v: $('#fieldName').value.trim() || '(not set)' }, { k: 'Mode', v: state.mode }, { k: MODE_LABELS[state.mode].target, v: target || '(not set)', mono: true }, ...(MODE_LABELS[state.mode].showRepo ? [{ k: 'Source repo', v: repo || '(not set)', mono: true }] : []), @@ -314,7 +318,9 @@ function renderReview() { $('#btnLaunch').addEventListener('click', startExploitation); async function startExploitation() { + if (!validateStep(0)) { goToStep(0); return; } const mode = state.mode; + const name = $('#fieldName').value.trim(); const target = $('#fieldTarget').value.trim(); const repo = $('#fieldRepo').value.trim(); const provider = $('#fieldProvider').value; @@ -323,6 +329,7 @@ async function startExploitation() { const body = { mode, + name, target: mode === 'whitebox' ? undefined : target, repo: mode === 'whitebox' ? target : (repo || undefined), models: provider && model ? [`${provider}:${model}`] : [], @@ -344,7 +351,7 @@ async function startExploitation() { $('#btnLaunch').textContent = 'Starting…'; try { const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - attachLiveJob(id, body.target || body.repo); + attachLiveJob(id, body.target || body.repo, name); } catch (e) { alert('Failed to start: ' + e.message); } finally { @@ -367,14 +374,15 @@ function bindRunTabs(scopeEl) { bindRunTabs($('#liveView')); bindRunTabs($('#detailView')); -function attachLiveJob(id, target) { +function attachLiveJob(id, target, name) { if (state.currentJob?.es) state.currentJob.es.close(); - state.currentJob = { id, es: null, findings: [], target, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null, runId: null }; + state.currentJob = { id, es: null, findings: [], target, name, phase: 'starting', agents: 0, agentsDone: 0, reportUrl: null, runId: null }; show($('#wizardView'), false); show($('#detailView'), false); show($('#liveView'), true); - $('#liveTarget').textContent = target || '—'; + $('#liveTarget').textContent = name || target || '—'; + $('#liveTargetSub').textContent = name ? target : ''; $('#livePhase').textContent = 'starting'; $('#phaseDot').style.background = ''; $('#liveFindingsTable tbody').innerHTML = ''; @@ -525,7 +533,7 @@ function renderSidebar() { for (const r of g.items) { const btn = document.createElement('button'); btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : ''); - btn.innerHTML = `${esc(r.target)}${esc(r.id)} · ${r.findings} finding(s)`; + btn.innerHTML = `${esc(r.name || r.target)}${r.name ? esc(r.target) + ' · ' : ''}${r.findings} finding(s)`; btn.addEventListener('click', () => openRun(r)); items.appendChild(btn); const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId; @@ -556,7 +564,9 @@ function openRun(run) { async function loadDetail(id) { clearInterval(state.detailPoll); const detail = await api(`/api/runs/${encodeURIComponent(id)}`); - $('#detailTarget').textContent = detail.status?.target || detail.meta?.target || id; + const target = detail.status?.target || detail.meta?.target || id; + $('#detailTarget').textContent = detail.name || target; + $('#detailTargetSub').textContent = detail.name ? target : ''; $('#detailState').textContent = detail.status?.state || 'unknown'; $('#detailFindingsCount').textContent = detail.findings.length; const tbody = $('#detailFindingsTable tbody'); diff --git a/web/public/index.html b/web/public/index.html index bc72024..de78500 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -57,6 +57,11 @@
+
+ + +
Identifies this engagement in the sidebar and run history — required.
+
What are you testing?
Pick the engagement type — this decides which CLI subcommand runs underneath.
@@ -197,6 +202,7 @@
+
starting
@@ -226,6 +232,7 @@
+
diff --git a/web/server.js b/web/server.js index b9352f6..318d649 100644 --- a/web/server.js +++ b/web/server.js @@ -31,6 +31,25 @@ 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() { @@ -277,6 +296,7 @@ async function listRuns() { return { id, ts, + name: engagementNames.get(id) || '', target: status.target || meta.target || id.replace(/^ns-\d+-/, ''), state: status.state || 'unknown', findings: findings.length, @@ -298,7 +318,7 @@ async function runDetail(id) { ]); const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md'] .filter((f) => fs.existsSync(path.join(dir, f))); - return { id, meta, status, findings, assets }; + return { id, name: engagementNames.get(id) || '', meta, status, findings, assets }; } function safeRunDir(id) { @@ -317,12 +337,13 @@ function safeRunDir(id) { const jobs = new Map(); // id -> Job class Job extends EventEmitter { - constructor(id, cmd, args, target) { + constructor(id, cmd, args, target, name) { super(); this.id = id; this.cmd = cmd; this.args = args; this.target = target || ''; + this.name = name || ''; this.runId = null; // ns-- workdir basename, once known this.phase = 'starting'; this.findings = []; @@ -344,6 +365,7 @@ class Job extends EventEmitter { return { id: this.id, target: this.target, + name: this.name, runId: this.runId, phase: this.phase, findings: this.findings, @@ -392,7 +414,7 @@ function ingestLine(job, rawLine) { if (rep) job.reportUrl = rep[1]; const rid = line.match(/run id\s*:\s*(\S+)/); - if (rid) job.runId = rid[1]; + if (rid) { job.runId = rid[1]; saveEngagementName(job.runId, job.name); } } function buildArgs(body) { @@ -428,7 +450,7 @@ async function startJob(body) { 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 || ''); + const job = new Job(id, BIN, args, body.repo || body.target || '', body.name || ''); jobs.set(id, job); const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });