mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-25 10:52:31 +02:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129WdYHccPsH27k5GGuwijd
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bb659412fc
commit
3dcfeb7377
+9
-2
@@ -94,14 +94,15 @@ Lists `runs/ns-*` directories, newest first, with a summary read from each run's
|
|||||||
`meta.json` / `status.json` / `findings.json`.
|
`meta.json` / `status.json` / `findings.json`.
|
||||||
|
|
||||||
```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`.
|
`state` mirrors the CLI's `status.json`: `running` | `complete` | `stopped-raw` | `discarded` | `unknown`.
|
||||||
|
|
||||||
### `GET /api/runs/:id`
|
### `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
|
`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`).
|
files exist (`report.html`, `report.pdf`, `report.md`, `recon.md`, `exploitation.md`).
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ Body:
|
|||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"mode": "run", // run | whitebox | greybox | host | aitest | skills
|
"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
|
"target": "https://example.com", // required for run/host/aitest/greybox
|
||||||
"repo": "owner/repo", // required for whitebox; source repo for greybox
|
"repo": "owner/repo", // required for whitebox; source repo for greybox
|
||||||
"models": ["anthropic:claude-opus-4-8"], // optional, repeatable in the CLI
|
"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 —
|
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.
|
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": "<job-uuid>" }`. This `id` is the **web job id**, not the run id — the CLI's own
|
Response: `{ "id": "<job-uuid>" }`. This `id` is the **web job id**, not the run id — the CLI's own
|
||||||
`ns-<timestamp>-<target>` run id is discovered from its own log line and exposed as `runId` in the
|
`ns-<timestamp>-<target>` run id is discovered from its own log line and exposed as `runId` in the
|
||||||
job snapshot once the engagement starts writing to `runs/`.
|
job snapshot once the engagement starts writing to `runs/`.
|
||||||
|
|||||||
+17
-7
@@ -98,6 +98,7 @@ function goToStep(n) {
|
|||||||
|
|
||||||
function validateStep(n) {
|
function validateStep(n) {
|
||||||
if (n === 0) {
|
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();
|
const target = $('#fieldTarget').value.trim();
|
||||||
if (!target) { alert(`${MODE_LABELS[state.mode].target} is required.`); return false; }
|
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; }
|
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() {
|
function updateWizardSummary() {
|
||||||
|
const name = $('#fieldName').value.trim() || '(unnamed)';
|
||||||
const target = $('#fieldTarget').value.trim() || '(not set)';
|
const target = $('#fieldTarget').value.trim() || '(not set)';
|
||||||
$('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · <b>${esc(state.mode)}</b> · <b>${esc(target)}</b>`;
|
$('#wizardSummary').innerHTML = `Step ${state.step + 1} of ${STEP_COUNT} · <b>${esc(name)}</b> · ${esc(state.mode)} · ${esc(target)}`;
|
||||||
}
|
}
|
||||||
|
$('#fieldName').addEventListener('input', updateWizardSummary);
|
||||||
|
|
||||||
// mode tiles
|
// mode tiles
|
||||||
function selectMode(mode) {
|
function selectMode(mode) {
|
||||||
@@ -292,6 +295,7 @@ function renderReview() {
|
|||||||
const provider = $('#fieldProvider').value;
|
const provider = $('#fieldProvider').value;
|
||||||
const model = $('#fieldModelSelect').value;
|
const model = $('#fieldModelSelect').value;
|
||||||
const items = [
|
const items = [
|
||||||
|
{ k: 'Engagement name', v: $('#fieldName').value.trim() || '(not set)' },
|
||||||
{ k: 'Mode', v: state.mode },
|
{ k: 'Mode', v: state.mode },
|
||||||
{ k: MODE_LABELS[state.mode].target, v: target || '(not set)', mono: true },
|
{ 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 }] : []),
|
...(MODE_LABELS[state.mode].showRepo ? [{ k: 'Source repo', v: repo || '(not set)', mono: true }] : []),
|
||||||
@@ -314,7 +318,9 @@ function renderReview() {
|
|||||||
$('#btnLaunch').addEventListener('click', startExploitation);
|
$('#btnLaunch').addEventListener('click', startExploitation);
|
||||||
|
|
||||||
async function startExploitation() {
|
async function startExploitation() {
|
||||||
|
if (!validateStep(0)) { goToStep(0); return; }
|
||||||
const mode = state.mode;
|
const mode = state.mode;
|
||||||
|
const name = $('#fieldName').value.trim();
|
||||||
const target = $('#fieldTarget').value.trim();
|
const target = $('#fieldTarget').value.trim();
|
||||||
const repo = $('#fieldRepo').value.trim();
|
const repo = $('#fieldRepo').value.trim();
|
||||||
const provider = $('#fieldProvider').value;
|
const provider = $('#fieldProvider').value;
|
||||||
@@ -323,6 +329,7 @@ async function startExploitation() {
|
|||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
mode,
|
mode,
|
||||||
|
name,
|
||||||
target: mode === 'whitebox' ? undefined : target,
|
target: mode === 'whitebox' ? undefined : target,
|
||||||
repo: mode === 'whitebox' ? target : (repo || undefined),
|
repo: mode === 'whitebox' ? target : (repo || undefined),
|
||||||
models: provider && model ? [`${provider}:${model}`] : [],
|
models: provider && model ? [`${provider}:${model}`] : [],
|
||||||
@@ -344,7 +351,7 @@ async function startExploitation() {
|
|||||||
$('#btnLaunch').textContent = 'Starting…';
|
$('#btnLaunch').textContent = 'Starting…';
|
||||||
try {
|
try {
|
||||||
const { id } = await api('/api/exploit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
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) {
|
} catch (e) {
|
||||||
alert('Failed to start: ' + e.message);
|
alert('Failed to start: ' + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -367,14 +374,15 @@ function bindRunTabs(scopeEl) {
|
|||||||
bindRunTabs($('#liveView'));
|
bindRunTabs($('#liveView'));
|
||||||
bindRunTabs($('#detailView'));
|
bindRunTabs($('#detailView'));
|
||||||
|
|
||||||
function attachLiveJob(id, target) {
|
function attachLiveJob(id, target, name) {
|
||||||
if (state.currentJob?.es) state.currentJob.es.close();
|
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($('#wizardView'), false);
|
||||||
show($('#detailView'), false);
|
show($('#detailView'), false);
|
||||||
show($('#liveView'), true);
|
show($('#liveView'), true);
|
||||||
$('#liveTarget').textContent = target || '—';
|
$('#liveTarget').textContent = name || target || '—';
|
||||||
|
$('#liveTargetSub').textContent = name ? target : '';
|
||||||
$('#livePhase').textContent = 'starting';
|
$('#livePhase').textContent = 'starting';
|
||||||
$('#phaseDot').style.background = '';
|
$('#phaseDot').style.background = '';
|
||||||
$('#liveFindingsTable tbody').innerHTML = '';
|
$('#liveFindingsTable tbody').innerHTML = '';
|
||||||
@@ -525,7 +533,7 @@ function renderSidebar() {
|
|||||||
for (const r of g.items) {
|
for (const r of g.items) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
btn.className = 'sb-run' + (state.currentDetailId === r.id ? ' active' : '');
|
||||||
btn.innerHTML = `<span class="name">${esc(r.target)}</span><span class="sub">${esc(r.id)} · ${r.findings} finding(s)</span>`;
|
btn.innerHTML = `<span class="name">${esc(r.name || r.target)}</span><span class="sub">${r.name ? esc(r.target) + ' · ' : ''}${r.findings} finding(s)</span>`;
|
||||||
btn.addEventListener('click', () => openRun(r));
|
btn.addEventListener('click', () => openRun(r));
|
||||||
items.appendChild(btn);
|
items.appendChild(btn);
|
||||||
const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId;
|
const isThisJob = r.state === 'running' && state.currentJob && r.id === state.currentJob.runId;
|
||||||
@@ -556,7 +564,9 @@ function openRun(run) {
|
|||||||
async function loadDetail(id) {
|
async function loadDetail(id) {
|
||||||
clearInterval(state.detailPoll);
|
clearInterval(state.detailPoll);
|
||||||
const detail = await api(`/api/runs/${encodeURIComponent(id)}`);
|
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';
|
$('#detailState').textContent = detail.status?.state || 'unknown';
|
||||||
$('#detailFindingsCount').textContent = detail.findings.length;
|
$('#detailFindingsCount').textContent = detail.findings.length;
|
||||||
const tbody = $('#detailFindingsTable tbody');
|
const tbody = $('#detailFindingsTable tbody');
|
||||||
|
|||||||
@@ -57,6 +57,11 @@
|
|||||||
|
|
||||||
<!-- Step 1 — Asset -->
|
<!-- Step 1 — Asset -->
|
||||||
<div class="wizard-panel" data-panel="0">
|
<div class="wizard-panel" data-panel="0">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field-label">Engagement name</label>
|
||||||
|
<input id="fieldName" type="text" placeholder="e.g. Keystone – Digital Banking" />
|
||||||
|
<div class="field-help">Identifies this engagement in the sidebar and run history — required.</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="section-title">What are you testing?</div>
|
<div class="section-title">What are you testing?</div>
|
||||||
<div class="section-desc">Pick the engagement type — this decides which CLI subcommand runs underneath.</div>
|
<div class="section-desc">Pick the engagement type — this decides which CLI subcommand runs underneath.</div>
|
||||||
@@ -197,6 +202,7 @@
|
|||||||
<header class="run-head">
|
<header class="run-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="run-target" id="liveTarget">—</div>
|
<div class="run-target" id="liveTarget">—</div>
|
||||||
|
<div class="run-meta" id="liveTargetSub" style="font-family: var(--mono);"></div>
|
||||||
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
<div class="run-meta"><span class="phase-dot" id="phaseDot"></span><span id="livePhase">starting</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="run-actions">
|
<div class="run-actions">
|
||||||
@@ -226,6 +232,7 @@
|
|||||||
<header class="run-head">
|
<header class="run-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="run-target" id="detailTarget">—</div>
|
<div class="run-target" id="detailTarget">—</div>
|
||||||
|
<div class="run-meta" id="detailTargetSub" style="font-family: var(--mono);"></div>
|
||||||
<div class="run-meta"><span class="phase-dot static" id="detailDot"></span><span id="detailState">—</span></div>
|
<div class="run-meta"><span class="phase-dot static" id="detailDot"></span><span id="detailState">—</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="run-actions">
|
<div class="run-actions">
|
||||||
|
|||||||
+26
-4
@@ -31,6 +31,25 @@ const WEB_DIR = __dirname;
|
|||||||
const ROOT = path.resolve(WEB_DIR, '..'); // repo root — holds agents_md/, runs/
|
const ROOT = path.resolve(WEB_DIR, '..'); // repo root — holds agents_md/, runs/
|
||||||
const AGENTS_DIR = path.join(ROOT, 'agents_md');
|
const AGENTS_DIR = path.join(ROOT, 'agents_md');
|
||||||
const RUNS_DIR = path.join(ROOT, 'runs');
|
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');
|
const PUBLIC_DIR = path.join(WEB_DIR, 'public');
|
||||||
|
|
||||||
function findBinary() {
|
function findBinary() {
|
||||||
@@ -277,6 +296,7 @@ async function listRuns() {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
ts,
|
ts,
|
||||||
|
name: engagementNames.get(id) || '',
|
||||||
target: status.target || meta.target || id.replace(/^ns-\d+-/, ''),
|
target: status.target || meta.target || id.replace(/^ns-\d+-/, ''),
|
||||||
state: status.state || 'unknown',
|
state: status.state || 'unknown',
|
||||||
findings: findings.length,
|
findings: findings.length,
|
||||||
@@ -298,7 +318,7 @@ async function runDetail(id) {
|
|||||||
]);
|
]);
|
||||||
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
const assets = ['report.html', 'report.pdf', 'report.md', 'recon.md', 'exploitation.md']
|
||||||
.filter((f) => fs.existsSync(path.join(dir, f)));
|
.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) {
|
function safeRunDir(id) {
|
||||||
@@ -317,12 +337,13 @@ function safeRunDir(id) {
|
|||||||
const jobs = new Map(); // id -> Job
|
const jobs = new Map(); // id -> Job
|
||||||
|
|
||||||
class Job extends EventEmitter {
|
class Job extends EventEmitter {
|
||||||
constructor(id, cmd, args, target) {
|
constructor(id, cmd, args, target, name) {
|
||||||
super();
|
super();
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.cmd = cmd;
|
this.cmd = cmd;
|
||||||
this.args = args;
|
this.args = args;
|
||||||
this.target = target || '';
|
this.target = target || '';
|
||||||
|
this.name = name || '';
|
||||||
this.runId = null; // ns-<ts>-<target> workdir basename, once known
|
this.runId = null; // ns-<ts>-<target> workdir basename, once known
|
||||||
this.phase = 'starting';
|
this.phase = 'starting';
|
||||||
this.findings = [];
|
this.findings = [];
|
||||||
@@ -344,6 +365,7 @@ class Job extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
target: this.target,
|
target: this.target,
|
||||||
|
name: this.name,
|
||||||
runId: this.runId,
|
runId: this.runId,
|
||||||
phase: this.phase,
|
phase: this.phase,
|
||||||
findings: this.findings,
|
findings: this.findings,
|
||||||
@@ -392,7 +414,7 @@ function ingestLine(job, rawLine) {
|
|||||||
if (rep) job.reportUrl = rep[1];
|
if (rep) job.reportUrl = rep[1];
|
||||||
|
|
||||||
const rid = line.match(/run id\s*:\s*(\S+)/);
|
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) {
|
function buildArgs(body) {
|
||||||
@@ -428,7 +450,7 @@ async function startJob(body) {
|
|||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const credsPath = await materializeCreds(body, id);
|
const credsPath = await materializeCreds(body, id);
|
||||||
const args = buildArgs({ ...body, creds: credsPath });
|
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);
|
jobs.set(id, job);
|
||||||
|
|
||||||
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||||
|
|||||||
Reference in New Issue
Block a user