v4.3.0: web console parity + report redaction + per-finding wire evidence & re-test

Harness / CLI
- redact: credentials become $SECRET_n in every human-facing report
  (md/html/json/sarif/typ, findings.md); findings.json & vault keep raw values.
- poc: `--only <id> --json` re-tests a single finding (powers the web Re-test).
- version bump 4.2.4 → 4.3.0 (Cargo, CLI banner, web).

Web console
- Attack graph replaces "Generative Attack Path Chaining": one tab, findings
  ordered by kill-chain stage, click a finding to open it. Mounted in detail + live.
- Finding modal: Wire evidence — the real request/response pairs behind a finding
  (baseline/attack, identity pair, matrix probes, oracle) with wire provenance,
  the wire-check summary, and recorded flows on the same endpoint.
- Per-finding Re-test with dated history (reproduced/changed/gone/unverifiable).
- Transcript viewer in the Traces tab (navigable blocks; search spans all agents).
- Regression vs the previous run of the same target: new / recurring badges and a
  "fixed since" banner.
- Chains tab: readable formatting (node refs as chips, relations as labels).
- Custom lead: describe-it (Claude writes it) OR paste your own agent/system
  prompt (verbatim); remove a custom lead from the board.
- GDPR added to compliance mapping; LLM API keys tab clarified; Reasoning-budget
  and Authorization layouts fixed; NeuroSploit logo added.

Docs / config
- README rewritten as a concise landing page; TUTORIAL.md updated to v4.3.0 with
  new web/graph/Neo4j/wire sections and first-time configuration.
- .env.example: v4.3.0, Z.ai/Moonshot keys, Neo4j + graph-salt + capability env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 4.8 committed 2026-10-14 19:42:16 -03:00
1 parent 79685cee49
commit e9da74a981
13 files changed
+991 -1036

No files matched your search

+3 -1
View File
@@ -297,6 +297,8 @@ to the CLI — the console never re-implements harness logic.
| `POST` | `/api/runs/:id/graph` | Recompute with `neurosploit graph <run>`; body `{ "push": true }` adds `--push` (Neo4j credentials from the server's `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD`). Returns the CLI output and the refreshed graph. |
| `GET` | `/api/runs/:id/traffic?host=&method=&q=&offset=&limit=` | The recording interceptor's `flows.jsonl`, filtered, newest first; `hosts` lists every host seen. |
| `GET` | `/api/runs/:id/traces?q=` | The shared agent traces (`traces/*.md`) and, with `q`, line hits across all of them. |
| `POST` | `/api/runs/:id/retest` | `neurosploit poc <run>` — re-runs every finding's recorded proof and reports what still reproduces (report only; nothing is written back). |
| `POST` | `/api/runs/:id/retest` | Body `{}` → `neurosploit poc <run>`, re-runs every finding's proof (report only). Body `{ "finding": "<id>" }` → `neurosploit poc <run> --only <id> --json`, re-tests one finding, appends the dated outcome to `retests/<id>.json`, and returns `{ result, history }`. Nothing is written back to `findings.json`. |
| `GET` | `/api/runs/:id/retests/:findingId` | The re-test history for one finding — each entry `{ at, reproduction, detail, … }`, oldest first. |
| `GET` | `/api/runs/:id/findings/:findingId/traffic` | The HTTP exchanges that back one finding: the exchanges the harness measured (`evidence_data` — baseline/attack, the identity pair, matrix probes, the oracle), each with its wire `provenance`; the finding's `wire` check summary; and the recorded `flows.jsonl` entries on the same endpoint. Real bytes, not the agent's prose. |
`GET /api/runs/:id` now also returns `extras`: `{ graph, todo, traces, flows, deliverables[] }`.
+9
View File
@@ -18,6 +18,15 @@ CLI binary, never a reimplementation of harness logic.
credential → impact, every edge marked confirmed / needs validation / hypothesis, findings ordered
by kill-chain stage. Refreshed live at each graph checkpoint; click a finding to open it. Chains
and Dependencies list the same paths as text and as the step-by-step todolist.
- **Wire evidence in the finding** — the finding modal shows the real request/response pairs the
harness measured (baseline vs. attack, the identity pair, matrix probes, the oracle), each tagged
with its wire provenance, plus the finding's wire-check summary and any recorded flow on the same
endpoint. The proof as bytes, not as the agent's prose.
- **Per-finding re-test with history** — ⟳ Re-test re-runs one finding's recorded proof through the
scope-guarded replay engine and keeps a dated history (reproduced / changed / gone / unverifiable),
so a finding drifting to "gone" over time is visible. Nothing is written back to the findings.
- **Transcript viewer** — the Traces tab renders a chosen agent's transcript split into navigable,
collapsible blocks at each tool-call / HTTP-request boundary; search still spans every agent.
- **Terminal dock (xterm.js)** — `❭_` in the sidebar, the topbar button, or `Ctrl+\`` opens a
docked terminal running a real `neurosploit` REPL session. Its stdout is streamed **unstripped**,
so the harness's own colour and box-drawn panels render as they do in a local shell. Line
+295 -19
View File
@@ -247,9 +247,15 @@ function renderBoard() {
</label>
<span class="agent-title">${esc(a.title)}</span>
${a.cwe ? `<span class="agent-cwe">${esc(a.cwe)}</span>` : ''}
${String(a.id).startsWith('custom_') ? `<button class="agent-remove" data-remove="${esc(a.id)}" title="Remove this custom lead">✕</button>` : ''}
`;
rows.appendChild(row);
}
rows.querySelectorAll('.agent-remove').forEach((btn) => btn.addEventListener('click', (e) => {
e.stopPropagation();
const a = group.agents.find((x) => x.id === btn.dataset.remove);
removeCustomAgent(btn.dataset.remove, a?.title);
}));
card.querySelector('.cat-head').addEventListener('click', (e) => {
if (e.target.closest('.switch')) return;
const nowCollapsed = card.classList.toggle('collapsed');
@@ -341,9 +347,18 @@ function renderCustomLeads() {
// `prompt()` gave a one-line box with no room to describe a lead, no way to
// see the wizard behind it, and no place to report a generation failure.
function setLeadMode(mode) {
state.leadMode = mode;
$$('#leadModeTabs .seg-tab').forEach((t) => t.classList.toggle('active', t.dataset.leadmode === mode));
$$('[data-leadpanel]').forEach((p) => show(p, p.dataset.leadpanel === mode));
$('#btnLeadGenerate').textContent = mode === 'own' ? 'Add lead' : 'Generate lead';
}
function openLeadModal() {
$('#leadDesc').value = '';
$('#leadName') && ($('#leadName').value = '');
$('#leadOwn') && ($('#leadOwn').value = '');
fieldError('#errLead', '');
setLeadMode('describe');
show($('#leadModal'), true);
$('#leadDesc').focus();
}
@@ -352,14 +367,39 @@ $('#btnCustomLead').addEventListener('click', openLeadModal);
$('#btnCloseLead').addEventListener('click', closeLeadModal);
$('#btnLeadCancel').addEventListener('click', closeLeadModal);
$('#leadModal').addEventListener('click', (e) => { if (e.target.id === 'leadModal') closeLeadModal(); });
$$('#leadModeTabs .seg-tab').forEach((t) => t.addEventListener('click', () => setLeadMode(t.dataset.leadmode)));
$('#leadDesc').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) $('#btnLeadGenerate').click();
});
$('#btnLeadGenerate').addEventListener('click', async () => {
const text = $('#leadDesc').value.trim();
if (!text) { fieldError('#errLead', 'Describe what the lead should test.'); return; }
const mode = state.leadMode || 'describe';
const btn = $('#btnLeadGenerate');
const original = btn.textContent;
// Mode B — the operator's own agent / system prompt, used verbatim.
if (mode === 'own') {
const md = $('#leadOwn').value.trim();
const title = $('#leadName').value.trim();
if (!md) { fieldError('#errLead', 'Paste your agent markdown or system prompt.'); return; }
btn.disabled = true; btn.textContent = 'Adding…';
try {
const { agent } = await api('/api/leads/custom', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, markdown: md }),
});
await loadAgents();
if (agent) state.selected.add(agent.id);
renderBoard();
closeLeadModal();
toast(`Added and pinned: ${agent?.title || title || 'custom lead'}`, 'ok');
} catch (e) {
fieldError('#errLead', e.message);
} finally {
btn.disabled = false; btn.textContent = original;
}
return;
}
// Mode A — Claude writes the agent from a description.
const text = $('#leadDesc').value.trim();
if (!text) { fieldError('#errLead', 'Describe what the lead should test.'); return; }
btn.disabled = true;
btn.textContent = 'Generating…';
try {
@@ -383,6 +423,20 @@ $('#btnLeadGenerate').addEventListener('click', async () => {
btn.textContent = original;
}
});
// Delete a custom lead (the operator's own custom_* agent file) from the board.
async function removeCustomAgent(id, title) {
if (!confirm(`Remove custom lead “${title || id}”? This deletes its agent file.`)) return;
try {
await api(`/api/leads/${encodeURIComponent(id)}`, { method: 'DELETE' });
state.selected.delete(id);
await loadAgents();
renderBoard();
toast('Custom lead removed', 'ok');
} catch (e) {
toast(`Could not remove: ${e.message}`, 'error');
}
}
$('#btnExpandAll').addEventListener('click', () => {
const anyCollapsed = $$('.cat-card').some((c) => c.classList.contains('collapsed'));
state.expandedCats = anyCollapsed ? new Set(state.categories.map((g) => g.category)) : new Set();
@@ -499,7 +553,7 @@ function renderReview() {
{ k: 'Sandbox', v: $('#fieldSandbox').value ? 'Kali container' : 'host' },
{ k: 'PoC re-validation', v: $('#fieldRevalidatePoc').checked ? 'on' : 'off' },
{ k: 'TypeSafe', v: $('#fieldTypesafe') ? $('#fieldTypesafe').value : 'auto' },
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2', 'fieldCompGdpr'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
];
$('#reviewGrid').innerHTML = items.map((it) => `
@@ -547,7 +601,7 @@ async function startExploitation() {
sandbox: $('#fieldSandbox').value || undefined,
revalidatePoc: $('#fieldRevalidatePoc').checked,
typesafe: $('#fieldTypesafe') ? $('#fieldTypesafe').value : undefined,
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2', 'fieldCompGdpr'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
tokenLimit: Number($('#fieldTokenLimit').value) || undefined,
order: $('#fieldOrder').value,
samplePerRoute: Number($('#fieldSampleRoute').value) || undefined,
@@ -742,9 +796,12 @@ function reviewStatus(f) {
function findingRow(f, idx) {
const w = wireState(f);
const st = reviewStatus(f);
const reg = state.detailRegression?.status?.[f.id];
const regBadge = reg === 'recurring' ? '<span class="reg-badge reg-recurring" title="Also present in the previous run of this target">recurring</span>'
: reg === 'new' ? '<span class="reg-badge reg-new" title="Not in the previous run of this target">new</span>' : '';
return `<tr data-idx="${idx}">
<td><span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span></td>
<td>${esc(f.title)}</td>
<td>${esc(f.title)}${regBadge}</td>
<td class="col-endpoint" title="${esc(f.endpoint)}">${esc(f.endpoint)}</td>
<td class="col-cwe">${esc(f.cwe)}</td>
<td><span class="status-badge st-${esc(st)}">${esc(st)}</span></td>
@@ -1064,11 +1121,124 @@ function openFindingModal(f, pocs, runId) {
}).catch(() => {});
}
}
// Wire evidence — the real request/response pairs behind the finding, plus
// its re-test history. Loaded on demand; the section hides itself when the
// finding carries no replayable exchange and has never been re-tested.
state.fmFinding = { f, runId };
loadWireEvidence(f, runId);
show($('#findingModal'), true);
}
$('#btnCloseFinding').addEventListener('click', () => show($('#findingModal'), false));
$('#findingModal').addEventListener('click', (e) => { if (e.target.id === 'findingModal') show($('#findingModal'), false); });
// ---- wire evidence + per-finding re-test -----------------------------------
const PROV_LABEL = {
'wire:replay': 'wire · harness replay', 'wire:recorded': 'wire · interceptor',
'wire:harness': 'wire · harness', '': 'agent-reported',
};
const REPRO_CLASS = { reproduced: 'ok', changed: 'warn', gone: 'bad', unverifiable: 'muted' };
async function loadWireEvidence(f, runId) {
const summary = $('#fmWireSummary');
const exRoot = $('#fmWireExchanges');
summary.innerHTML = '';
exRoot.innerHTML = '<div class="field-help">loading…</div>';
let data = {};
try { data = await api(`/api/runs/${encodeURIComponent(runId)}/findings/${encodeURIComponent(f.id)}/traffic`); } catch { data = {}; }
// A later modal may have opened while this was in flight.
if (state.fmFinding?.f?.id !== f.id) return;
const w = data.wire;
if (w) {
const chips = [];
if (w.reproduced) chips.push(`<span class="wire-chip ok">${w.reproduced} reproduced by replay</span>`);
if (w.corroborated) chips.push(`<span class="wire-chip ok">${w.corroborated} corroborated on wire</span>`);
if (w.harness_observed) chips.push(`<span class="wire-chip ok">${w.harness_observed} harness-observed</span>`);
if (w.unverified) chips.push(`<span class="wire-chip muted">${w.unverified} unverifiable</span>`);
(w.contradicted || []).forEach((c) => chips.push(`<span class="wire-chip bad" title="${esc(c)}">contradicted</span>`));
(w.absent_from_capture || []).length && chips.push(`<span class="wire-chip warn">${w.absent_from_capture.length} not seen by interceptor</span>`);
summary.innerHTML = `<div class="wire-chips">${chips.join('')}</div>` +
((w.contradicted || []).length ? `<ul class="wire-contra">${w.contradicted.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '');
}
const ex = data.exchanges || [];
const flows = data.flows || [];
exRoot.innerHTML =
(ex.length ? ex.map(exchangeCard).join('') : '') +
(flows.length ? `<div class="wire-sub">Recorded flows on this endpoint (${flows.length})</div>` + flows.map(flowRow).join('') : '') +
(!ex.length && !flows.length ? '<div class="field-help">No replayable exchange recorded for this finding. Re-test sends its proof again if one exists.</div>' : '');
loadRetestHistory(f.id, runId);
}
function exchangeCard(ex) {
const prov = PROV_LABEL[ex.provenance] ?? ex.provenance ?? 'agent-reported';
const wireCls = String(ex.provenance || '').startsWith('wire:') ? 'ok' : 'muted';
const st = ex.status >= 500 ? 'tf-5' : ex.status >= 400 ? 'tf-4' : ex.status >= 300 ? 'tf-3' : ex.status >= 200 ? 'tf-2' : 'tf-0';
const reqH = Object.entries(ex.request_headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
const resH = Object.entries(ex.headers || {}).map(([k, v]) => `${k}: ${v}`).join('\n');
const body = (ex.body || '').slice(0, 4000);
return `<details class="wire-ex">
<summary>
<span class="wire-role">${esc(ex.role || '')}</span>
<span class="method">${esc(ex.method || 'GET')}</span>
<span class="wire-url" title="${esc(ex.url)}">${esc(ex.url || '')}</span>
<span class="tf ${st}">${ex.status || '—'}</span>
${ex.identity ? `<span class="wire-id">as ${esc(ex.identity)}</span>` : ''}
<span class="wire-prov ${wireCls}">${esc(prov)}</span>
</summary>
<div class="wire-ex-body">
${reqH ? `<div class="wire-h"><b>Request headers</b><pre class="poc-pre">${esc(reqH)}</pre></div>` : ''}
${resH ? `<div class="wire-h"><b>Response headers</b><pre class="poc-pre">${esc(resH)}</pre></div>` : ''}
${body ? `<div class="wire-h"><b>Response body${ex.body.length > 4000 ? ' (first 4 KB)' : ''}</b><pre class="poc-pre">${esc(body)}</pre></div>` : ''}
</div>
</details>`;
}
function flowRow(r) {
const st = r.status >= 500 ? 'tf-5' : r.status >= 400 ? 'tf-4' : r.status >= 300 ? 'tf-3' : r.status >= 200 ? 'tf-2' : 'tf-0';
return `<div class="wire-flow"><span class="method">${esc(r.method || '')}</span>
<span class="wire-url" title="${esc(r.url)}">${esc(r.url || '')}</span>
<span class="tf ${st}">${r.tunnelled ? 'TLS' : r.status || '—'}</span></div>`;
}
async function loadRetestHistory(fid, runId) {
const root = $('#fmRetestHistory');
let h = [];
try { h = (await api(`/api/runs/${encodeURIComponent(runId)}/retests/${encodeURIComponent(fid)}`)).history || []; } catch { h = []; }
if (state.fmFinding?.f?.id !== fid) return;
renderRetestHistory(h);
}
function renderRetestHistory(h) {
const root = $('#fmRetestHistory');
if (!h.length) { root.innerHTML = ''; return; }
root.innerHTML = `<div class="retest-hist"><div class="wire-sub">Re-test history</div>${h.slice().reverse().map((e) => {
const cls = REPRO_CLASS[e.reproduction] || 'muted';
return `<div class="retest-row"><span class="wire-chip ${cls}">${esc(e.reproduction || 'error')}</span>
<span class="retest-when">${e.at ? new Date(e.at * 1000).toLocaleString() : ''}</span>
<span class="retest-detail" title="${esc(e.detail || e.raw || '')}">${esc(e.detail || e.raw || '')}</span></div>`;
}).join('')}</div>`;
}
$('#btnFindingRetest').addEventListener('click', async (e) => {
const cur = state.fmFinding;
if (!cur?.f || !cur.runId) return;
const btn = e.currentTarget;
btn.disabled = true; const label = btn.textContent; btn.textContent = '⟳ re-testing…';
try {
const r = await api(`/api/runs/${encodeURIComponent(cur.runId)}/retest`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ finding: cur.f.id }) });
if (state.fmFinding?.f?.id === cur.f.id) renderRetestHistory(r.history || []);
toast(`Re-test: ${r.result?.reproduction || 'done'}`, (REPRO_CLASS[r.result?.reproduction] === 'bad' ? 'error' : 'ok'));
} catch (err) {
toast(`Re-test failed: ${err.message}`, 'error');
} finally {
btn.disabled = false; btn.textContent = label;
}
});
/// Exactly where the problem is: parameter/field/flow, not just the URL.
function locationLine(f) {
const loc = (f.location || '').trim();
@@ -1403,6 +1573,32 @@ function openRun(run) {
renderSidebar();
}
async function loadRegression(id, token) {
const box = $('#detailRegression');
let r = null;
try { r = await api(`/api/runs/${encodeURIComponent(id)}/regression`); } catch { r = null; }
if (token !== state.detailToken) return;
if (!r || !r.prev) { state.detailRegression = null; show(box, false); return; }
state.detailRegression = r;
renderFindings('detail'); // repaint so the new/recurring badges show
// Count over the findings actually shown, so the banner matches the table
// even when a run's findings.json reuses an id.
const cur = state.detailFindings || [];
const recurring = cur.filter((f) => r.status?.[f.id] === 'recurring').length;
const fresh = cur.filter((f) => r.status?.[f.id] === 'new').length;
const fixed = r.fixed || [];
const when = r.prev.when ? timeAgo(Math.floor(new Date(r.prev.when).getTime() / 1000)) : '';
box.innerHTML = `<div class="reg-banner">
<span class="reg-h">vs previous run</span>
<span class="muted">${esc(when)}</span>
<span class="reg-chip reg-new">${fresh} new</span>
<span class="reg-chip reg-recurring">${recurring} recurring</span>
<span class="reg-chip reg-fixed">${fixed.length} fixed</span>
${fixed.length ? `<details class="reg-fixed-list"><summary>fixed since</summary><ul>${fixed.map((x) => `<li><span class="sev ${sevClass(x.severity)}">${esc(x.severity)}</span> ${esc(x.title)}</li>`).join('')}</ul></details>` : ''}
</div>`;
show(box, true);
}
async function loadDetail(id) {
clearInterval(state.detailPoll);
// A poll for the previously opened run may still be in flight; when it
@@ -1424,7 +1620,11 @@ async function loadDetail(id) {
$('#detailState').textContent = detail.status?.state || 'unknown';
state.detailFindings = detail.findings;
state.detailPocs = detail.pocs || [];
// Regression vs the previous run of this target — new / recurring badges and a
// "fixed since" banner. Best-effort: it never blocks the findings table.
state.detailRegression = null;
renderFindings('detail');
loadRegression(id, token);
// A past run's identity: when it ran, how many agents, what asset the recon
// decided it was. Previously the page showed only target + state, so two
// runs of the same target were indistinguishable.
@@ -2614,21 +2814,42 @@ function renderChains(x, g) {
<span class="chain-meta">conf ${Number(c.confidence || 0).toFixed(2)} · blast ${c.blast_radius} · ${esc(c.exploitability)}</span>
</summary>
<div class="chain-body">
<div class="chain-impact"><b>Impact:</b> ${esc(c.impact)}</div>
<ol class="chain-steps">${(c.step_text || []).map((s, j) => `<li class="step st-${esc(c.steps?.[j]?.status || '')}">${esc(s)}</li>`).join('')}</ol>
${block('Findings involved', (c.findings || []).map((f) => `${f.title} — ${f.severity} (${f.status})`))}
${block('Evidence references', c.evidence, 'none recorded — this chain is unproven')}
${block('Required assumptions', c.assumptions, 'none — every step is evidenced')}
${block('Recommended validation', c.validation, 'none needed — confirmed end to end')}
${block('Recommended remediation', c.remediation)}
<div class="chain-impact"><b>Impact:</b> ${chainText(c.impact)}</div>
<ol class="chain-steps">${(c.step_text || []).map((s, j) => `<li class="step st-${esc(c.steps?.[j]?.status || '')}">${chainText(s)}</li>`).join('')}</ol>
${block('Findings involved', (c.findings || []).map((f) => `${chainText(f.title)} <span class="sev ${sevClass(f.severity)}">${esc(f.severity)}</span> <span class="chain-note">${esc(f.status)}</span>`), null, true)}
${block('Evidence references', (c.evidence || []).map(chainText), 'none recorded — this chain is unproven', true)}
${block('Required assumptions', (c.assumptions || []).map(chainText), 'none — every step is evidenced', true)}
${block('Recommended validation', (c.validation || []).map(chainText), 'none needed — confirmed end to end', true)}
${block('Recommended remediation', (c.remediation || []).map(chainText), null, true)}
</div>
</details>`).join('');
}
function block(title, items, emptyText) {
// Turn the graph's prose into readable markup: node names (backtick-quoted) become
// trimmed code references, relation tokens (—[ESCALATES_TO]→) become labelled
// chips, and the "(needs_validation, conf 0.80)" tail becomes a muted note.
function chainText(s) {
let e = esc(String(s || ''));
// Node references: `…` → <code>, with mid-word ellipsis trimmed to a word edge.
e = e.replace(/`([^`]*)`/g, (_m, inner) => {
let t = inner;
if (t.endsWith('…')) t = t.replace(/\s*\S*…$/, '…');
return `<code class="chain-ref" title="${inner}">${t}</code>`;
});
// Generic node-type words before a reference are noise once it is a code chip.
e = e.replace(/\b(?:Finding|BusinessImpact|Host|Service|Endpoint|Credential|Identity|AttackStep|Vulnerability)\s+(?=<code)/g, '');
// Relation tokens, em-dash or ASCII arrow form.
e = e.replace(/\s*(?:—|-)\[([A-Z_]+)\](?:→|-&gt;|->)\s*/g, ' <span class="chain-rel">$1</span> ');
// Trailing status/confidence note.
e = e.replace(/\(([a-z_]+,\s*conf\s*[0-9.]+)\)/g, '<span class="chain-note">$1</span>');
return e;
}
function block(title, items, emptyText, html) {
const arr = items || [];
if (!arr.length && !emptyText) return '';
return `<div class="chain-block"><div class="chain-block-h">${esc(title)}</div><ul>${arr.length ? arr.map((v) => `<li>${esc(v)}</li>`).join('') : `<li class="muted">${esc(emptyText)}</li>`}</ul></div>`;
const cell = (v) => html ? v : esc(v);
return `<div class="chain-block"><div class="chain-block-h">${esc(title)}</div><ul>${arr.length ? arr.map((v) => `<li>${cell(v)}</li>`).join('') : `<li class="muted">${esc(emptyText)}</li>`}</ul></div>`;
}
// ---- dependency todolist ---------------------------------------------------
@@ -2887,14 +3108,69 @@ async function extrasTraces(x) {
x.count('traces', (r.traces || []).length);
show(x.q('tr-empty'), !(r.traces || []).length);
const base = `/api/runs/${encodeURIComponent(id)}/asset/`;
x.q('tr-files').innerHTML = (r.traces || []).map((t) => `<li><a href="${base}${encodeURI(t.file)}" target="_blank">${esc(t.agent)}</a><span class="muted">${(t.bytes / 1024).toFixed(1)} KB</span></li>`).join('');
x.q('tr-note').textContent = qv ? `${r.hits.length}${r.hits.length >= 200 ? '+' : ''} hit(s) for “${qv}”` : `${(r.traces || []).length} agent trace(s) — search across all of them`;
x.q('tr-files').innerHTML = (r.traces || []).map((t) =>
`<li><button class="trace-file-btn${x.trAgent === t.agent ? ' active' : ''}" data-agent="${esc(t.agent)}" data-file="${esc(t.file)}">${esc(t.agent)}</button><span class="muted">${(t.bytes / 1024).toFixed(1)} KB</span></li>`).join('');
$$('.trace-file-btn', x.q('tr-files')).forEach((b) => b.addEventListener('click', () => showTranscript(x, b.dataset.agent, b.dataset.file)));
x.q('tr-note').textContent = qv ? `${r.hits.length}${r.hits.length >= 200 ? '+' : ''} hit(s) for “${qv}”` : `${(r.traces || []).length} agent trace(s) — click one to read it, or search across all`;
const re = qv ? new RegExp(qv.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig') : null;
x.q('tr-hits').innerHTML = qv
? (r.hits.length ? r.hits.map((h) => `<div class="trace-hit"><a href="${base}${encodeURI(h.file)}" target="_blank">${esc(h.agent)}</a><span class="muted">:${h.line}</span><code>${esc(h.snippet).replace(re, (m) => `<mark>${m}</mark>`)}</code></div>`).join('') : '<div class="muted">No agent saw that.</div>')
: '<div class="muted">Type a keyword — an error string, a parameter, a version — to see which agents already observed it.</div>';
if (qv) {
x.trAgent = null;
x.q('tr-hits').innerHTML = r.hits.length
? r.hits.map((h) => `<div class="trace-hit"><button class="trace-file-btn" data-agent="${esc(h.agent)}" data-file="${esc(h.file)}" data-line="${h.line}">${esc(h.agent)}</button><span class="muted">:${h.line}</span><code>${esc(h.snippet).replace(re, (m) => `<mark>${m}</mark>`)}</code></div>`).join('')
: '<div class="muted">No agent saw that.</div>';
$$('.trace-file-btn', x.q('tr-hits')).forEach((b) => b.addEventListener('click', () => showTranscript(x, b.dataset.agent, b.dataset.file, qv)));
} else if (x.trAgent) {
const cur = (r.traces || []).find((t) => t.agent === x.trAgent);
if (cur) showTranscript(x, cur.agent, cur.file);
else x.q('tr-hits').innerHTML = '<div class="muted">Click an agent on the left to read its transcript.</div>';
} else {
x.q('tr-hits').innerHTML = '<div class="muted">Click an agent on the left to read its transcript, or type a keyword to search across all of them.</div>';
}
}
// A transcript is mostly target output and tool noise. Render it readable:
// group by the tool-call / HTTP-request boundaries the harness writes, and let
// each block collapse, so a long run is navigable rather than one wall of text.
const TR_STEP_RE = /^(?:#{1,4}\s|\$\s|>\s|(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT)\s+\S|(?:TOOL|STEP|AGENT|ROUND|PHASE)\b|={3,}|-{3,}\s*$)/i;
async function showTranscript(x, agent, file, highlight) {
x.trAgent = agent;
$$('.trace-file-btn', x.q('tr-files')).forEach((b) => b.classList.toggle('active', b.dataset.agent === agent));
const hits = x.q('tr-hits');
hits.innerHTML = '<div class="field-help">loading…</div>';
const id = x.getRunId();
let text = '';
try { text = await (await fetch(`/api/runs/${encodeURIComponent(id)}/asset/${encodeURI(file)}`)).text(); } catch { hits.innerHTML = '<div class="muted">Could not load trace.</div>'; return; }
if (x.trAgent !== agent) return;
text = stripAnsiJs(text);
const lines = text.split('\n');
// Split into blocks at each step/request boundary.
const blocks = [];
let cur = null;
for (const line of lines) {
if (TR_STEP_RE.test(line) || !cur) { cur = { head: line, body: [] }; blocks.push(cur); }
else cur.body.push(line);
}
const re = highlight ? new RegExp(highlight.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig') : null;
const mark = (s) => { const e = esc(s); return re ? e.replace(re, (m) => `<mark>${m}</mark>`) : e; };
hits.innerHTML = `<div class="transcript">
<div class="transcript-head"><b>${esc(agent)}</b><span class="muted">${blocks.length} block(s) · ${lines.length} line(s)</span>
<a class="btn btn-sm" href="/api/runs/${encodeURIComponent(id)}/asset/${encodeURI(file)}" target="_blank">Open raw</a></div>
${blocks.map((b) => {
const body = b.body.join('\n').replace(/\s+$/, '');
const open = re && (re.test(b.head) || re.test(body));
re && (re.lastIndex = 0);
return `<details class="transcript-block"${open ? ' open' : ''}>
<summary>${mark(b.head.slice(0, 200)) || '<span class="muted">(blank)</span>'}</summary>
${body ? `<pre class="poc-pre">${mark(body)}</pre>` : ''}
</details>`;
}).join('')}
</div>`;
}
/// Strip ANSI escapes client-side (traces are served raw).
function stripAnsiJs(s) { return String(s || '').replace(/\u001b\[[0-9;]*m/g, ''); }
// ---- deliverables ----------------------------------------------------------
const DELIV_INFO = {
+55 -17
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NeuroSploit v4.2.4 — Console</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>">
<title>NeuroSploit v4.3.0 — Console</title>
<link rel="icon" type="image/svg+xml" href="logo.svg">
<link rel="stylesheet" href="/vendor/xterm.css" />
<link rel="stylesheet" href="/style.css" />
</head>
@@ -18,7 +18,20 @@
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar" id="sidebar">
<div class="sb-top">
<div class="brand"><span class="mark">NS</span> NeuroSploit</div>
<div class="brand">
<svg class="mark" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<path d="M9 39V11l20 20V11" stroke="currentColor" stroke-width="5.5" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M39 9V37" stroke="currentColor" stroke-width="5.5" stroke-linecap="square"/>
<g stroke="#22a8ff" stroke-width="2.4" fill="#22a8ff">
<path d="M12 16h7l6 6h8" fill="none"/>
<circle cx="11" cy="16" r="3.4"/><circle cx="34" cy="22" r="3.4"/>
<circle cx="38" cy="10" r="3.4"/><circle cx="15" cy="37" r="3.4"/>
<path d="M18 34l-3 3" fill="none"/>
</g>
<path d="M41 7L18 41" stroke="#22a8ff" stroke-width="2.6" stroke-linecap="round"/>
</svg>
NeuroSploit
</div>
<button class="icon-btn" id="btnThemeToggle" title="Toggle light / dark theme">☾</button>
</div>
@@ -33,7 +46,7 @@
<div class="sb-groups" id="sbGroups"><!-- populated by app.js --></div>
<div class="sb-bottom">
<span class="sb-version" id="sbVersion">v4.2.4</span>
<span class="sb-version" id="sbVersion">v4.3.0</span>
<div class="sb-bottom-actions">
<button class="icon-btn" id="btnOpenAuth" title="Auth &amp; API keys">🔑</button>
<button class="icon-btn" id="btnOpenRepl" title="Open terminal (Ctrl+`)">❭_</button>
@@ -220,13 +233,13 @@
<div class="section-title">Reasoning budget</div>
<div class="section-desc">Optional. Left on <em>unlimited</em>, the run behaves exactly as it always has — full depth, no cap.</div>
</div>
<div class="check-col">
<div class="check-row"><input type="checkbox" id="fieldResearch" /> <label for="fieldResearch"><b>🔬 Research mode</b> — hunt a novel, CVE-reportable bug (whitebox/greybox)</label></div>
<div class="field-help check-help">Known-CVE de-dup + patch-diff variant analysis. Best with a source repo; reports only genuinely new issues (or a concrete patch bypass).</div>
<div class="check-row check-quick"><input type="checkbox" id="fieldQuick" /> <label for="fieldQuick"><b>⚡ Quick mode</b> — short, low-cost test</label></div>
<div class="field-help check-help">Economy preset: 1 voter, 1 chain round, light recon, ≤6 agents, eco budget. The big token saver. Wins over the settings below.</div>
</div>
<div class="field-row">
<div class="check-row"><input type="checkbox" id="fieldResearch" /> <label for="fieldResearch"><b>🔬 Research mode</b> — hunt a novel, CVE-reportable bug (whitebox/greybox)</label>
<div class="field-help">Known-CVE de-dup + patch-diff variant analysis. Best with a source repo; reports only genuinely new issues (or a concrete patch bypass).</div>
</div>
<div class="check-row check-quick"><input type="checkbox" id="fieldQuick" /> <label for="fieldQuick"><b>⚡ Quick mode</b> — short, low-cost test</label>
<div class="field-help">Economy preset: 1 voter, 1 chain round, light recon, ≤6 agents, eco budget. The big token saver. Wins over the settings below.</div>
</div>
<div class="field-group"><label class="field-label" for="fieldBudget">Budget mode</label>
<select class="narrow" id="fieldBudget">
<option value="unlimited" selected>unlimited · full run (default)</option>
@@ -289,6 +302,7 @@
<div class="check-row"><input type="checkbox" id="fieldCompPci" value="pci-dss" /> <label for="fieldCompPci">PCI-DSS v4.0</label></div>
<div class="check-row"><input type="checkbox" id="fieldCompHipaa" value="hipaa" /> <label for="fieldCompHipaa">HIPAA Security Rule</label></div>
<div class="check-row"><input type="checkbox" id="fieldCompSoc2" value="soc2" /> <label for="fieldCompSoc2">SOC 2 (Trust Services Criteria)</label></div>
<div class="check-row"><input type="checkbox" id="fieldCompGdpr" value="gdpr" /> <label for="fieldCompGdpr">GDPR (data-protection articles)</label></div>
<div class="field-help">Maps confirmed findings onto control requirements in the report. Indicates gaps for an assessor — never a compliance verdict.</div>
</div>
</div>
@@ -414,6 +428,7 @@
<button class="btn btn-sm" id="btnExportCsv" title="Export the filtered findings as CSV">⤓ CSV</button>
</div>
<div class="kpi-row" id="detailKpis"></div>
<div id="detailRegression" hidden></div>
<div class="table-wrap">
<table class="data-table" id="detailFindingsTable">
<thead><tr><th data-sort="severity">Severity</th><th data-sort="title">Title</th><th data-sort="endpoint">Endpoint</th><th data-sort="cwe">CWE</th><th data-sort="review_status">Status</th><th data-sort="wire">Wire</th><th data-sort="agent">Agent</th><th data-sort="confidence" class="col-conf">Conf.</th></tr></thead>
@@ -460,7 +475,7 @@
</div>
<div class="modal-tabs">
<button class="modal-tab active" data-mtab="target">Target auth</button>
<button class="modal-tab" data-mtab="keys">API keys</button>
<button class="modal-tab" data-mtab="keys">LLM API keys</button>
<button class="modal-tab" data-mtab="creds">Creds file</button>
<button class="modal-tab" data-mtab="authz">Authorization</button>
</div>
@@ -479,7 +494,8 @@
</div>
</div>
<div class="modal-panel" data-mpanel="keys" hidden>
<div class="field-help" style="margin-bottom:12px;">Keys are kept in this server process's memory only — never written to disk. Cleared on restart.</div>
<div class="section-title">Model provider keys</div>
<div class="field-help" style="margin:4px 0 12px;">The API keys for the <b>LLMs that run the engagement</b> (not the target's auth — that's the <em>Target auth</em> tab). Kept in this server process's memory only, never written to disk, cleared on restart.</div>
<div id="providerKeyList"></div>
</div>
<div class="modal-panel" data-mpanel="authz" hidden>
@@ -500,11 +516,11 @@
<div class="field-help">Without this the engagement is authorized against the target and nothing else — discovering a host is not permission to test it.</div>
</div>
<div class="section-title" style="margin-top:20px;display:flex;align-items:center;gap:8px;">
<div class="section-title" style="margin-top:24px;margin-bottom:8px;display:flex;align-items:center;gap:8px;">
Scoping &amp; Guardrails
<span class="pill" style="font-size:10px;">enforced in code</span>
</div>
<div class="field-help" style="margin:-6px 0 10px;">
<div class="field-help" style="margin:0 0 14px;">
The <b>hard scope</b> is the boundary: a request whose host is not listed is <b>refused before it is sent</b> — not warned about. Exclusions always win. A capability token still caps all of this. Leave the hard list empty to keep the plain target + <em>Additional authorized hosts</em> behaviour.
</div>
<div class="field-group">
@@ -628,16 +644,28 @@
<button class="icon-btn" id="btnCloseLead">✕</button>
</div>
<div class="modal-body">
<div class="field-group">
<div class="field-help" style="margin-bottom:14px;">A <b>lead</b> is a specialist agent the harness runs. Add one two ways — let Claude write it from a description, or paste your own agent / system prompt to run verbatim. Either way it is saved as a real, pinnable agent in <code>agents_md/vulns/</code> and you can remove it later.</div>
<div class="seg-tabs" id="leadModeTabs">
<button class="seg-tab active" data-leadmode="describe">Describe it</button>
<button class="seg-tab" data-leadmode="own">Paste my own</button>
</div>
<div class="field-group" data-leadpanel="describe">
<label class="field-label" for="leadDesc">What should this lead test?</label>
<textarea id="leadDesc" placeholder="e.g. Abuse the invoice PDF export to read internal files via the template engine."></textarea>
<div class="field-help">Claude (Opus, subscription) generates a real specialist agent file for it and pins it. If generation is unavailable, the text is folded into <code>--focus</code> instead.</div>
<div class="field-help">Claude (Opus, subscription) writes a real specialist agent for it and pins it. If generation is unavailable, the text is folded into <code>--focus</code> instead.</div>
</div>
<div class="field-group" data-leadpanel="own" hidden>
<label class="field-label" for="leadName">Name</label>
<input id="leadName" type="text" placeholder="e.g. GraphQL Introspection Abuse" />
<label class="field-label" for="leadOwn" style="margin-top:10px;">Your agent / system prompt</label>
<textarea id="leadOwn" rows="10" placeholder="Paste a full agent markdown (# Title / ## User Prompt / ## System Prompt), or just your system prompt — it will be wrapped into an agent. Use {target} and {recon_json} placeholders."></textarea>
<div class="field-help">Used verbatim — no model rewrites it. {target} and {recon_json} are filled per run.</div>
</div>
<div class="field-error" id="errLead" hidden></div>
</div>
<div class="modal-foot">
<button class="btn" id="btnLeadCancel">Cancel</button>
<button class="btn btn-primary" id="btnLeadGenerate">Generate lead</button>
<button class="btn btn-primary" id="btnLeadGenerate">Add lead</button>
</div>
</div>
</div>
@@ -660,6 +688,16 @@
<div id="fmSection-remediation"></div>
<div id="fmSection-chains"></div>
<div class="field-group" id="fmSection-wire">
<div class="fm-wire-head">
<label class="field-label">Wire evidence — the bytes behind this finding</label>
<button class="btn btn-sm" id="btnFindingRetest">⟳ Re-test this finding</button>
</div>
<div id="fmWireSummary"></div>
<div id="fmRetestHistory"></div>
<div id="fmWireExchanges" class="wire-exchanges"></div>
</div>
<div class="field-group">
<label class="field-label">Other scripts from this run</label>
<div id="fmPocList" class="field-help">—</div>
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" color="#0b1220" role="img" aria-label="NeuroSploit">
<!-- angular N monogram (uses currentColor so it follows the theme) -->
<path d="M9 39V11l20 20V11" stroke="currentColor" stroke-width="5.5" stroke-linecap="square" stroke-linejoin="miter"/>
<path d="M39 9V37" stroke="currentColor" stroke-width="5.5" stroke-linecap="square"/>
<!-- cyan circuit trace + nodes -->
<g stroke="#22a8ff" stroke-width="2.4" fill="#22a8ff">
<path d="M12 16h7l6 6h8" fill="none"/>
<circle cx="11" cy="16" r="3.4"/>
<circle cx="34" cy="22" r="3.4"/>
<circle cx="38" cy="10" r="3.4"/>
<circle cx="15" cy="37" r="3.4"/>
<path d="M18 34l-3 3" fill="none"/>
</g>
<!-- diagonal slash -->
<path d="M41 7L18 41" stroke="#22a8ff" stroke-width="2.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 863 B

+74 -1
View File
@@ -140,7 +140,7 @@ a { color: var(--accent); text-decoration: none; }
display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-4) var(--sp-4) var(--sp-3);
}
.brand { display: flex; align-items: center; gap: var(--sp-2); font-weight: 600; font-size: var(--fs-base); letter-spacing: .01em; flex: 1; }
.brand .mark { width: 22px; height: 22px; border-radius: var(--radius-sm); background: var(--accent); color: var(--accent-contrast); display: flex; align-items: center; justify-content: center; font-size: var(--fs-sm); font-weight: 700; font-family: var(--mono); }
.brand .mark { width: 26px; height: 26px; flex: none; color: var(--text); }
.icon-btn {
background: transparent; border: 1px solid transparent; color: var(--text-dim);
@@ -393,6 +393,18 @@ textarea { resize: vertical; min-height: 72px; }
.auth-mode-toggle button:disabled { opacity: .45; cursor: not-allowed; }
.check-row { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-sm); color: var(--text-dim); }
/* a column of full-width checkbox rows (no squeezing into flex columns) */
.check-col { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--sp-4); }
.check-col .check-row { align-items: flex-start; }
.check-col .check-help { margin: 0 0 var(--sp-2) 26px; }
/* segmented tabs (lead modal mode) */
.seg-tabs { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; margin-bottom: 14px; }
.seg-tab { background: var(--surface); border: none; padding: 6px 14px; font: inherit; font-size: var(--fs-sm); color: var(--text-dim); cursor: pointer; }
.seg-tab + .seg-tab { border-left: 1px solid var(--border); }
.seg-tab.active { background: var(--accent); color: #fff; }
/* remove a custom lead from the board */
.agent-remove { margin-left: auto; background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-sm); padding: 0 4px; border-radius: var(--radius-xs); }
.agent-remove:hover { color: var(--sev-critical-fg); background: var(--sev-critical-bg); }
.role-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.role-row { display: flex; gap: var(--sp-2); align-items: center; }
@@ -833,6 +845,14 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
.chain-block { margin-top: var(--sp-2); }
.chain-block-h { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
.chain-block ul { margin: 4px 0 0; padding-left: 18px; font-size: var(--fs-sm); }
.chain-block li { margin: 3px 0; line-height: 1.5; }
/* readable chain prose: node refs as chips, relations as labels, meta muted */
.chain-steps { font-family: var(--sans); }
.chain-steps .step { line-height: 1.6; }
.chain-ref { font-family: var(--mono); font-size: 0.92em; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; color: var(--text); white-space: normal; }
.chain-rel { font-family: var(--mono); font-size: var(--fs-2xs); font-weight: 700; letter-spacing: .03em; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-xs); padding: 0 5px; margin: 0 2px; }
.chain-rel::before { content: ""; }
.chain-note { color: var(--text-faint); font-size: var(--fs-xs); font-family: var(--mono); margin-left: 4px; }
/* dependency todolist */
.todo-chain { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); margin-bottom: var(--sp-3); }
@@ -915,6 +935,59 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
.trace-hit a { font-weight: 600; color: var(--text); }
.trace-hit code { display: block; font-family: var(--mono); font-size: var(--fs-xs); color: var(--text-dim); margin-top: 3px; white-space: pre-wrap; word-break: break-all; }
.trace-hit mark { background: var(--accent-soft); color: var(--accent); }
.trace-file-btn { background: none; border: none; padding: 0; margin: 0; font: inherit; color: var(--text); cursor: pointer; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trace-file-btn:hover { color: var(--accent); }
.trace-file-btn.active { color: var(--accent); font-weight: 700; }
/* transcript viewer */
.transcript-head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-2); }
.transcript-head .muted { flex: 1; font-size: var(--fs-xs); }
.transcript-block { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); margin-bottom: 4px; }
.transcript-block > summary { cursor: pointer; padding: 5px var(--sp-3); font-family: var(--mono); font-size: var(--fs-xs); color: var(--text); white-space: pre-wrap; word-break: break-all; }
.transcript-block > summary mark { background: var(--accent-soft); color: var(--accent); }
.transcript-block pre { margin: 0; border-top: 1px solid var(--border); }
.transcript-block pre mark { background: var(--accent-soft); color: var(--accent); }
/* wire evidence (finding modal) */
.fm-wire-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.wire-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: var(--sp-2) 0; }
.wire-chip { font-size: var(--fs-2xs); font-weight: 700; padding: 2px 8px; border-radius: var(--radius-xs); border: 1px solid var(--border-strong); }
.wire-chip.ok { background: var(--sev-low-bg); color: var(--sev-low-fg); border-color: transparent; }
.wire-chip.warn { background: var(--sev-medium-bg); color: var(--sev-medium-fg); border-color: transparent; }
.wire-chip.bad { background: var(--sev-critical-bg); color: var(--sev-critical-fg); border-color: transparent; }
.wire-chip.muted { color: var(--text-faint); }
.wire-contra { margin: 0 0 var(--sp-2) 0; padding-left: var(--sp-5); font-size: var(--fs-xs); color: var(--sev-critical-fg); }
.wire-sub { font-size: var(--fs-xs); font-weight: 700; color: var(--text-dim); margin: var(--sp-3) 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.wire-exchanges { display: flex; flex-direction: column; gap: 4px; }
.wire-ex { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
.wire-ex > summary { cursor: pointer; display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); font-size: var(--fs-xs); flex-wrap: wrap; }
.wire-role { font-weight: 700; color: var(--text-dim); min-width: 96px; }
.wire-url { font-family: var(--mono); color: var(--text-dim); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.wire-id { font-size: var(--fs-2xs); color: var(--text-faint); }
.wire-prov { font-size: var(--fs-2xs); font-weight: 700; padding: 1px 6px; border-radius: var(--radius-xs); }
.wire-prov.ok { background: var(--sev-low-bg); color: var(--sev-low-fg); }
.wire-prov.muted { color: var(--text-faint); border: 1px solid var(--border); }
.wire-ex-body { padding: 0 var(--sp-3) var(--sp-3); }
.wire-h { margin-top: var(--sp-2); }
.wire-h b { font-size: var(--fs-2xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
.wire-flow { display: flex; align-items: center; gap: var(--sp-2); padding: 4px var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); font-size: var(--fs-xs); }
.retest-hist { margin: var(--sp-2) 0; }
.retest-row { display: flex; align-items: center; gap: var(--sp-3); padding: 3px 0; font-size: var(--fs-xs); }
.retest-when { color: var(--text-faint); font-family: var(--mono); font-size: var(--fs-2xs); white-space: nowrap; }
.retest-detail { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* regression vs previous run */
.reg-badge { font-size: var(--fs-2xs); font-weight: 700; padding: 0 6px; border-radius: var(--radius-xs); margin-left: 6px; vertical-align: middle; }
.reg-new { background: var(--sev-medium-bg); color: var(--sev-medium-fg); }
.reg-recurring { background: var(--sev-info-bg); color: var(--sev-info-fg); }
.reg-fixed { background: var(--sev-low-bg); color: var(--sev-low-fg); }
.reg-banner { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-3); padding: 6px var(--sp-3); margin-bottom: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); font-size: var(--fs-sm); }
.reg-banner .reg-h { font-weight: 700; text-transform: uppercase; letter-spacing: .04em; font-size: var(--fs-xs); color: var(--text-dim); }
.reg-chip { font-size: var(--fs-2xs); font-weight: 700; padding: 1px 8px; border-radius: var(--radius-xs); }
.reg-fixed-list { font-size: var(--fs-sm); }
.reg-fixed-list summary { cursor: pointer; color: var(--accent); }
.reg-fixed-list ul { margin: var(--sp-2) 0 0; padding-left: var(--sp-5); }
.reg-fixed-list li { margin: 2px 0; }
@media (max-width: 760px) { .trace-layout { grid-template-columns: 1fr; } .gm-wrap { height: 460px; } }
/* deliverables */
+194 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
'use strict';
/**
* NeuroSploit v4.2.4 — web console backend.
* NeuroSploit v4.3.0 — web console backend.
*
* Zero-dependency Node HTTP server that:
* - serves the static SPA in ./public
@@ -653,6 +653,128 @@ async function searchTraces(dir, needle) {
return { traces: list, hits, query: needle || '' };
}
/// The CLI prints progress before the `--json` payload; take the last line that
/// parses as JSON so a banner or a warning never breaks the parse.
function lastJsonLine(buf) {
const lines = stripAnsi(buf || '').split('\n').map((l) => l.trim()).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].startsWith('[') || lines[i].startsWith('{')) return lines[i];
}
return '[]';
}
/// A finding-id is used as a file name; keep it to the safe charset.
function safeId(id) { return /^[a-zA-Z0-9_.:-]+$/.test(id) ? id : null; }
async function readRetests(dir, findingId) {
const id = safeId(findingId);
if (!id) return [];
return await readJsonSafe(path.join(dir, 'retests', `${id}.json`), []);
}
/// Append one re-test outcome to the finding's dated history (newest last).
async function appendRetest(dir, findingId, entry) {
const id = safeId(findingId);
if (!id) return [];
const rdir = path.join(dir, 'retests');
await fsp.mkdir(rdir, { recursive: true }).catch(() => {});
const file = path.join(rdir, `${id}.json`);
const history = await readJsonSafe(file, []);
history.push(entry);
await fsp.writeFile(file, JSON.stringify(history, null, 2)).catch(() => {});
return history;
}
/// The real HTTP exchanges that back one finding: first the ones the harness
/// measured itself (the finding's own evidence_data — baseline/attack, the
/// identity pair, matrix probes, the oracle), each with its wire provenance;
/// then, when the recording interceptor ran, the recorded flows on the same
/// endpoint. This is the finding's proof as bytes, not as the agent's prose.
async function findingTraffic(dir, findingId) {
const findings = await readJsonSafe(path.join(dir, 'findings.json'), []);
const f = findings.find((x) => x.id === findingId);
if (!f) return { error: 'finding not found', exchanges: [], flows: [] };
const ev = f.evidence_data || {};
const exchanges = [];
const push = (role, ex) => { if (ex && (ex.url || ex.body)) exchanges.push({ role, ...ex }); };
push('baseline', ev.baseline);
push('attack', ev.attack);
(ev.repeats || []).forEach((ex, i) => push(`repeat ${i + 1}`, ex));
push('as owner', ev.identity_a);
push('as other identity', ev.identity_b);
(ev.matrix || []).forEach((p) => push(`matrix · ${p.identity || '?'} (expect ${p.expect || '?'})`, p.exchange));
if (ev.oracle) { push('oracle · should-be-denied', ev.oracle.denied); push('oracle · control', ev.oracle.control); }
// Recorded flows whose path matches the finding's endpoint, newest first.
let ep = '';
try { ep = new URL(f.endpoint).pathname; } catch { ep = String(f.endpoint || '').replace(/^https?:\/\/[^/]+/, '') || f.endpoint; }
const text = await fsp.readFile(path.join(dir, 'flows.jsonl'), 'utf8').catch(() => '');
const flows = text.split('\n').filter(Boolean)
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter((r) => r && ep && String(r.url || '').includes(ep))
.reverse().slice(0, 50);
return { finding: { id: f.id, title: f.title, endpoint: f.endpoint }, wire: ev.wire || null, exchanges, flows };
}
/// A stable identity for a finding across runs of the same target: its CWE (or,
/// lacking one, a slug of its title) plus the endpoint's path. Query strings and
/// object ids vary run to run, so they are dropped — the point is "the same bug",
/// not "the same request".
function findingKey(f) {
const cwe = String(f.cwe || '').trim().toUpperCase();
const base = cwe || String(f.title || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48);
let p = '';
try { p = new URL(f.endpoint).pathname; } catch { p = String(f.endpoint || '').replace(/^https?:\/\/[^/]+/, '').split('?')[0]; }
p = p.replace(/\/\d+(?=\/|$)/g, '/{id}').replace(/\/+$/, '') || '/';
return `${base}|${p}`;
}
const isReview = (f) => f.review_status === 'needs-review' || f.review_status === 'rejected';
/// Compare one finished run to the most recent EARLIER run of the same target:
/// which findings are new, which recur, and which were confirmed before and are
/// now gone. Runs over whatever is already on disk — no model, no network.
async function runRegression(dir, id) {
const norm = (t) => String(t || '').replace(/\/+$/, '').toLowerCase();
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 target = norm(status.target || meta.target);
const ts = (id.match(/^ns-(\d+)-/) || [])[1] | 0;
if (!target) return { prev: null, status: {}, fixed: [] };
// The newest earlier run of the same target.
let ids = [];
try { ids = (await fsp.readdir(RUNS_DIR)).filter((d) => d.startsWith('ns-') && d !== id); } catch { /* none */ }
let prev = null;
for (const other of ids) {
const ots = (other.match(/^ns-(\d+)-/) || [])[1] | 0;
if (!ots || ots >= ts) continue;
const [om, os] = await Promise.all([
readJsonSafe(path.join(RUNS_DIR, other, 'meta.json'), {}),
readJsonSafe(path.join(RUNS_DIR, other, 'status.json'), {}),
]);
if (norm(os.target || om.target) !== target) continue;
if (!prev || ots > prev.ts) prev = { id: other, ts: ots };
}
if (!prev) return { prev: null, status: {}, fixed: [] };
const prevFindings = await readJsonSafe(path.join(RUNS_DIR, prev.id, 'findings.json'), []);
const prevByKey = new Map();
for (const pf of prevFindings) {
const k = findingKey(pf);
if (!prevByKey.has(k)) prevByKey.set(k, pf);
}
const nowKeys = new Set(findings.map(findingKey));
// Per-finding verdict for the current run: recurring if the prior run had it.
const perFinding = {};
for (const f of findings) perFinding[f.id] = prevByKey.has(findingKey(f)) ? 'recurring' : 'new';
// Fixed: confirmed in the prior run, absent now.
const fixed = prevFindings
.filter((pf) => !isReview(pf) && !nowKeys.has(findingKey(pf)))
.map((pf) => ({ title: pf.title, severity: pf.severity, cwe: pf.cwe, endpoint: pf.endpoint }));
return { prev: { id: prev.id, ts: prev.ts, when: new Date(prev.ts * 1000).toISOString() }, status: perFinding, fixed };
}
function safeRunDir(id) {
if (!/^[a-zA-Z0-9_.-]+$/.test(id)) return null;
const dir = path.join(RUNS_DIR, id);
@@ -1341,6 +1463,40 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, { agent: created, raw: clean });
}
// ---- custom lead from the operator's OWN agent markdown (verbatim) ----
if (req.method === 'POST' && p === '/api/leads/custom') {
const body = await readBody(req);
const title = (body.title || '').trim();
let md = (body.markdown || '').trim();
if (!md) return sendJson(res, 400, { error: 'paste the agent markdown (or a system prompt)' });
// Accept a bare system prompt too: wrap it into the minimal agent shape so
// it is a real, pinnable agent like every other lead.
if (!/^#\s+/m.test(md) || !/##\s*User Prompt/i.test(md) || !/##\s*System Prompt/i.test(md)) {
const name = title || 'Custom Lead';
md = `# ${name} Agent\n## User Prompt\nYou are testing **{target}**.\n**Recon Context:**\n{recon_json}\n\n${md}\n\n### Report\n\`\`\`\nFINDING:\n- Title: ...\n- Severity: ...\n- CWE: CWE-...\n- Endpoint: [URL]\n- Evidence: ...\n- Impact: ...\n- Remediation: ...\n\`\`\`\n## System Prompt\n${md}\n`;
}
const titleMatch = md.match(/^#\s+(.+?)\s*$/m);
const slug = `custom_${slugify(title || (titleMatch && titleMatch[1]) || 'lead')}`;
await fsp.writeFile(path.join(AGENTS_DIR, 'vulns', `${slug}.md`), md.trim() + '\n');
agentCache = null;
const { agents } = await loadAgents();
return sendJson(res, 200, { agent: agents.find((a) => a.id === slug), raw: md });
}
// ---- delete a custom lead (only the operator's own custom_* agents) ----
const delLead = p.match(/^\/api\/leads\/([^/]+)$/);
if (req.method === 'DELETE' && delLead) {
const id = decodeURIComponent(delLead[1]);
if (!/^custom_[a-z0-9_]+$/.test(id)) return sendJson(res, 400, { error: 'only custom leads can be removed' });
const file = path.join(AGENTS_DIR, 'vulns', `${id}.md`);
if (!file.startsWith(path.join(AGENTS_DIR, 'vulns') + path.sep) || !fs.existsSync(file)) {
return sendJson(res, 404, { error: 'custom lead not found' });
}
await fsp.rm(file, { force: true });
agentCache = null;
return sendJson(res, 200, { ok: true, removed: id });
}
// ---- runs ----
if (req.method === 'GET' && p === '/api/runs') {
return sendJson(res, 200, await listRuns());
@@ -1577,10 +1733,45 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
const body = await readBody(req).catch(() => ({}));
const only = typeof body.finding === 'string' && body.finding ? body.finding : null;
// Per-finding re-test asks the CLI for JSON and keeps a dated history so
// the operator can see a finding drift from reproduced to gone over time.
if (only) {
const out = await runCli(['poc', dir, '--only', only, '--json']);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 're-test failed' });
let result = null;
try { result = (JSON.parse(lastJsonLine(out.buf)) || [])[0] || null; } catch { /* keep raw */ }
const entry = { at: Math.floor(Date.now() / 1000), ...(result || {}), raw: result ? undefined : out.buf.trim() };
const history = await appendRetest(dir, only, entry);
return sendJson(res, 200, { ok: true, result: entry, history });
}
const out = await runCli(['poc', dir]);
if (out.code !== 0) return sendJson(res, 502, { error: out.buf.trim() || 're-test failed' });
return sendJson(res, 200, { ok: true, output: out.buf.trim() });
}
// ---- per-finding re-test history ----
m = p.match(/^\/api\/runs\/([^/]+)\/retests\/([^/]+)$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, { history: await readRetests(dir, decodeURIComponent(m[2])) });
}
// ---- regression vs the previous run of the same target ----
m = p.match(/^\/api\/runs\/([^/]+)\/regression$/);
if (req.method === 'GET' && m) {
const id = decodeURIComponent(m[1]);
const dir = safeRunDir(id);
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await runRegression(dir, id));
}
// ---- recorded traffic that backs one finding ----
m = p.match(/^\/api\/runs\/([^/]+)\/findings\/([^/]+)\/traffic$/);
if (req.method === 'GET' && m) {
const dir = safeRunDir(decodeURIComponent(m[1]));
if (!dir || !fs.existsSync(dir)) return sendJson(res, 404, { error: 'run not found' });
return sendJson(res, 200, await findingTraffic(dir, decodeURIComponent(m[2])));
}
// ---- report rebuild (generate/refresh the PDF for a finished run) ----
m = p.match(/^\/api\/runs\/([^/]+)\/report$/);
@@ -1620,7 +1811,7 @@ const server = http.createServer(async (req, res) => {
}
if (req.method === 'GET' && p === '/api/meta') {
return sendJson(res, 200, { version: "4.2.4", binary: BIN, root: ROOT });
return sendJson(res, 200, { version: "4.3.0", binary: BIN, root: ROOT });
}
// ---- providers / API keys (in-memory only, never persisted) ----
@@ -1654,7 +1845,7 @@ const server = http.createServer(async (req, res) => {
loadPersistedJobs();
server.listen(PORT, () => {
console.log(`NeuroSploit v4.2.4 web console → http://localhost:${PORT}`);
console.log(`NeuroSploit v4.3.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}`);