mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-18 21:47:32 +02:00
feat(web): per-finding attack path, LLM config modal, severity badges, cloud mode
- Graph tab: sidebar with per-finding filter + Overview; description panel shows finding details when selected; walk-based subgraph isolation - Settings: single Config button opens modal with all LLM providers; removed redundant MCP Server card - Issues: colored severity badges (critical/high/medium/low/info) - Sidebar: removed key and terminal icons from footer - Server: cloud environment overrides, vault & guardrails config endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqW6BbQgqGkEDn3RYuCVoM
This commit is contained in:
1 parent
84a2cca4e8
commit
ecced9d713
4 files changed
+620
-30
No files matched your search
+325
-18
@@ -10,6 +10,7 @@ const MODE_LABELS = {
|
||||
greybox: { target: 'Target URL', help: 'The running application to exploit, alongside the source repo below.', showRepo: true, placeholder: 'https://target.example.com' },
|
||||
host: { target: 'Target host / IP', help: 'Runs Linux / Windows / Active Directory agents.', showRepo: false, placeholder: '10.0.0.10' },
|
||||
aitest: { target: 'AI endpoint URL', help: 'A live AI agent, LLM chat, or MCP endpoint (OWASP LLM Top 10).', showRepo: false, placeholder: 'https://target.example.com/chat' },
|
||||
cloud: { target: 'Cloud account / project', help: 'Test a connected cloud environment — select from configured AWS, GCP, or Azure integrations.', showRepo: false, placeholder: 'aws / gcp / azure', showCloudPicker: true },
|
||||
};
|
||||
|
||||
const STEP_COUNT = 5;
|
||||
@@ -101,7 +102,7 @@ function clearFieldErrors() { $$('.field-error').forEach((el) => { el.textConten
|
||||
// view navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALL_VIEWS = ['dashView','wizardView','liveView','detailView','pentestsView','prreviewsView','issuesView','reposView','domainsView','networksView','integrationsView','settingsView'];
|
||||
const ALL_VIEWS = ['dashView','wizardView','liveView','detailView','pentestsView','prreviewsView','issuesView','reposView','domainsView','networksView','vaultView','guardrailsView','integrationsView','settingsView'];
|
||||
|
||||
function switchView(viewName) {
|
||||
state.activeView = viewName;
|
||||
@@ -112,7 +113,7 @@ function switchView(viewName) {
|
||||
btn.classList.toggle('active', btn.dataset.view === viewName);
|
||||
});
|
||||
const runHist = $('#sbRunHistory');
|
||||
if (runHist) runHist.style.display = ['dash','wizard','live','detail'].includes(viewName) ? '' : 'none';
|
||||
if (runHist) runHist.style.display = ['live','detail'].includes(viewName) ? '' : 'none';
|
||||
if (viewName === 'pentests') loadPentests();
|
||||
if (viewName === 'issues') loadIssues();
|
||||
if (viewName === 'repos') loadRepos();
|
||||
@@ -121,6 +122,8 @@ function switchView(viewName) {
|
||||
if (viewName === 'integrations') loadIntegrations();
|
||||
if (viewName === 'settings') loadSettings();
|
||||
if (viewName === 'prreviews') loadPrReviews();
|
||||
if (viewName === 'vault') loadVault();
|
||||
if (viewName === 'guardrails') loadGuardrails();
|
||||
if (viewName === 'dash') renderDashboard();
|
||||
}
|
||||
|
||||
@@ -225,11 +228,39 @@ function selectMode(mode) {
|
||||
$('#targetHelp').textContent = cfg.help;
|
||||
$('#fieldTarget').placeholder = cfg.placeholder;
|
||||
show($('#fieldRepoGroup'), cfg.showRepo);
|
||||
show($('#fieldCloudGroup'), !!cfg.showCloudPicker);
|
||||
if (cfg.showCloudPicker) populateCloudPicker();
|
||||
updateWizardSummary();
|
||||
}
|
||||
$$('.mode-tile').forEach((tile) => tile.addEventListener('click', () => selectMode(tile.dataset.mode)));
|
||||
$('#fieldTarget').addEventListener('input', updateWizardSummary);
|
||||
|
||||
async function populateCloudPicker() {
|
||||
const sel = $('#fieldCloudProvider');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Select a cloud provider…</option>';
|
||||
try {
|
||||
const integs = await api('/api/config/integrations');
|
||||
const clouds = [
|
||||
{ id: 'aws', label: 'AWS' },
|
||||
{ id: 'gcp', label: 'GCP' },
|
||||
{ id: 'azure', label: 'Azure' },
|
||||
];
|
||||
for (const c of clouds) {
|
||||
const cfg = integs.find(i => i.id === c.id);
|
||||
const on = cfg?.enabled;
|
||||
sel.innerHTML += `<option value="${c.id}" ${on ? '' : 'disabled'}>${c.label}${on ? ' (connected)' : ' (not configured)'}</option>`;
|
||||
}
|
||||
} catch { /* keep empty */ }
|
||||
}
|
||||
$('#fieldCloudProvider')?.addEventListener('change', (e) => {
|
||||
const v = e.target.value;
|
||||
if (v) {
|
||||
$('#fieldTarget').value = v;
|
||||
updateWizardSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agents / lead board (step 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -659,6 +690,7 @@ async function startExploitation() {
|
||||
creds: state.credsPath || undefined,
|
||||
collectionPath: state.collectionPath || undefined,
|
||||
collectionData: state.collectionData || undefined,
|
||||
cloudProvider: mode === 'cloud' ? ($('#fieldCloudProvider')?.value || target) : undefined,
|
||||
capability: state.authz.capability || undefined,
|
||||
inScope: state.authz.inScope.split(/[,;\s]+/).filter(Boolean),
|
||||
environment: state.authz.environment,
|
||||
@@ -1642,7 +1674,8 @@ function openRun(run) {
|
||||
if (run.state === 'running') {
|
||||
resolveJobId(run).then((jid) => {
|
||||
if (!jid) { // no live job (server restarted): fall back to static detail
|
||||
show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true);
|
||||
ALL_VIEWS.forEach(id => { const el = $(`#${id}`); if (el) el.hidden = true; });
|
||||
show($('#detailView'), true);
|
||||
loadDetail(run.id); renderSidebar(); return;
|
||||
}
|
||||
localStorage.setItem(ACTIVE_JOB_KEY, jid);
|
||||
@@ -1656,7 +1689,8 @@ function openRun(run) {
|
||||
resumeFromSidebar(run);
|
||||
return;
|
||||
}
|
||||
show($('#wizardView'), false); show($('#liveView'), false); show($('#dashView'), false); show($('#detailView'), true);
|
||||
ALL_VIEWS.forEach(id => { const el = $(`#${id}`); if (el) el.hidden = true; });
|
||||
show($('#detailView'), true);
|
||||
loadDetail(run.id);
|
||||
renderSidebar();
|
||||
}
|
||||
@@ -1761,7 +1795,8 @@ function openAuthModal() {
|
||||
$('#collectionPath').value = state.collectionPath;
|
||||
refreshKeyStatus();
|
||||
}
|
||||
['#btnOpenAuth', '#btnOpenAuth2', '#btnOpenAuth3'].forEach((sel) => $(sel)?.addEventListener('click', openAuthModal));
|
||||
$('#btnOpenAuth')?.addEventListener('click', () => switchView('vault'));
|
||||
['#btnOpenAuth2', '#btnOpenAuth3'].forEach((sel) => $(sel)?.addEventListener('click', openAuthModal));
|
||||
$('#btnCloseAuth').addEventListener('click', () => show($('#authModal'), false));
|
||||
$('#authModal').addEventListener('click', (e) => { if (e.target.id === 'authModal') show($('#authModal'), false); });
|
||||
|
||||
@@ -2226,7 +2261,7 @@ function termOpen(mode) {
|
||||
function termClose() { show($('#termDock'), false); }
|
||||
function termToggle() { $('#termDock').hidden ? termOpen() : termClose(); }
|
||||
|
||||
$('#btnOpenRepl').addEventListener('click', () => termOpen());
|
||||
$('#btnOpenRepl')?.addEventListener('click', () => termOpen());
|
||||
$('#btnOpenTerm2').addEventListener('click', () => termOpen());
|
||||
$('#btnOpenTerm3').addEventListener('click', () => termOpen());
|
||||
$('#btnSendPromptTerm').addEventListener('click', () => termOpen('job'));
|
||||
@@ -2644,12 +2679,14 @@ function showDashboard() {
|
||||
}
|
||||
// Sidebar nav items
|
||||
$$('.sb-nav-item').forEach(btn => {
|
||||
if (!btn.dataset.view) return;
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.dataset.view === 'wizard') { leaveLiveJob(); clearInterval(state.detailPoll); }
|
||||
switchView(btn.dataset.view);
|
||||
if (window.matchMedia(NARROW).matches) setSidebar(false);
|
||||
});
|
||||
});
|
||||
$('#sbTerminal')?.addEventListener('click', () => termOpen());
|
||||
$('#btnDashRefresh')?.addEventListener('click', renderDashboard);
|
||||
$('#dashRange')?.addEventListener('change', renderDashboard);
|
||||
$('#runFilter')?.addEventListener('input', (e) => { state.runFilter = e.target.value; renderSidebar(); });
|
||||
@@ -2680,6 +2717,7 @@ window.matchMedia(NARROW).addEventListener('change', (m) => { if (!m.matches) se
|
||||
|
||||
async function boot() {
|
||||
applyTheme();
|
||||
switchView('dash');
|
||||
selectMode('run');
|
||||
goToStep(0);
|
||||
renderCustomLeads();
|
||||
@@ -2810,12 +2848,21 @@ function extrasPanelsHtml(uid) {
|
||||
<div data-x="todo"></div><div class="empty-state" data-x="todo-empty" hidden>Nothing pending — every chain is confirmed, or there is no multi-step path yet.</div>
|
||||
</div>
|
||||
<div class="run-tab-panel" data-tabpanel="graph" hidden>
|
||||
<div class="panel-toolbar"><div class="chip-row" data-x="legend"></div><div class="topbar-spacer"></div>
|
||||
<label class="toggle"><input type="checkbox" data-x="full" /> full graph</label></div>
|
||||
<div class="gm-wrap" data-x="wrap"><div class="gm-stage" data-x="stage"></div>
|
||||
<div class="gm-zoom"><button class="btn btn-sm" data-z="in">+</button><button class="btn btn-sm" data-z="out">−</button><button class="btn btn-sm" data-z="fit">⤢</button></div></div>
|
||||
<div class="gm-detail" data-x="node" hidden></div>
|
||||
<div class="empty-state" data-x="graph-empty" hidden>No attack graph yet — it is written at every graph checkpoint of a run, or press ↻ Graph on a finished run.</div>
|
||||
<div class="graph-split">
|
||||
<div class="graph-finding-list" data-x="gflist">
|
||||
<div class="gfl-header">Attack Paths</div>
|
||||
<button class="gfl-item active" data-gf="overview">Overview</button>
|
||||
</div>
|
||||
<div class="graph-main">
|
||||
<div class="panel-toolbar"><div class="chip-row" data-x="legend"></div><div class="topbar-spacer"></div>
|
||||
<label class="toggle"><input type="checkbox" data-x="full" /> full graph</label></div>
|
||||
<div class="gm-finding-desc" data-x="fdesc" hidden></div>
|
||||
<div class="gm-wrap" data-x="wrap"><div class="gm-stage" data-x="stage"></div>
|
||||
<div class="gm-zoom"><button class="btn btn-sm" data-z="in">+</button><button class="btn btn-sm" data-z="out">−</button><button class="btn btn-sm" data-z="fit">⤢</button></div></div>
|
||||
<div class="gm-detail" data-x="node" hidden></div>
|
||||
<div class="empty-state" data-x="graph-empty" hidden>No attack graph yet — it is written at every graph checkpoint of a run, or press ↻ Graph on a finished run.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="run-tab-panel" data-tabpanel="traffic" hidden>
|
||||
<div class="panel-toolbar">
|
||||
@@ -2902,7 +2949,7 @@ function extrasCounts(x) {
|
||||
async function extrasRefresh(x, extras, force) {
|
||||
if (!x) return;
|
||||
const id = x.getRunId();
|
||||
if (x.lastId !== id) { x.lastId = id; x.graphData = null; x.view = { k: 1, tx: 20, ty: 20, sel: null }; }
|
||||
if (x.lastId !== id) { x.lastId = id; x.graphData = null; x.view = { k: 1, tx: 20, ty: 20, sel: null, filterFinding: null }; }
|
||||
x.extras = extras || {};
|
||||
try { await extrasGraph(x, force); } catch { /* no graph yet */ }
|
||||
extrasCounts(x);
|
||||
@@ -3055,16 +3102,42 @@ function gmName(n) {
|
||||
return p.title || p.name || p.category || p.path || p.type || p.address || n.key;
|
||||
}
|
||||
|
||||
function renderGraphFindingList(x) {
|
||||
const data = x.graphData?.graph;
|
||||
const list = x.q('gflist');
|
||||
if (!list || !data) return;
|
||||
const findings = (data.nodes || []).filter(n => n.label === 'Finding');
|
||||
findings.sort((a, b) => sevRank(a.props?.local_severity) - sevRank(b.props?.local_severity));
|
||||
const sel = x.view.filterFinding || 'overview';
|
||||
list.innerHTML = `<div class="gfl-header">Attack Paths</div>
|
||||
<button class="gfl-item ${sel === 'overview' ? 'active' : ''}" data-gf="overview">Overview (${findings.length})</button>
|
||||
${findings.map(f => {
|
||||
const sev = f.props?.local_severity || 'info';
|
||||
return `<button class="gfl-item ${sel === f.key ? 'active' : ''}" data-gf="${esc(f.key)}" title="${esc(gmName(f))}">
|
||||
<span class="gfl-sev sev ${sevClass(sev)}">${esc(sev.charAt(0).toUpperCase())}</span>
|
||||
<span class="gfl-title">${esc(gmName(f))}</span>
|
||||
</button>`;
|
||||
}).join('')}`;
|
||||
$$('[data-gf]', list).forEach(btn => btn.addEventListener('click', () => {
|
||||
x.view.filterFinding = btn.dataset.gf === 'overview' ? null : btn.dataset.gf;
|
||||
x.view.sel = null;
|
||||
x.drawnSig = null;
|
||||
renderGraphMemory(x);
|
||||
}));
|
||||
}
|
||||
|
||||
function renderGraphMemory(x) {
|
||||
const data = x.graphData?.graph;
|
||||
const stage = x.q('stage');
|
||||
const has = !!(data && (data.nodes || []).length);
|
||||
show(x.q('graph-empty'), !has);
|
||||
show(x.q('wrap'), has);
|
||||
renderGraphFindingList(x);
|
||||
x.q('legend').innerHTML = [['asset', 'asset / host'], ['surface', 'service / endpoint'], ['weak', 'vulnerability / finding'], ['step', 'attack step'], ['cred', 'credential / identity'], ['impact', 'impact']]
|
||||
.map(([k, l]) => `<span class="gm-leg gk-${k}">${esc(l)}</span>`).join('') +
|
||||
'<span class="gm-leg-sep"></span><span class="gm-edge-leg es-confirmed">confirmed</span><span class="gm-edge-leg es-needs_validation">needs validation</span><span class="gm-edge-leg es-hypothesis">hypothesis</span>';
|
||||
if (!has) { stage.innerHTML = ''; return; }
|
||||
const filterFid = x.view.filterFinding || null;
|
||||
|
||||
const full = x.q('full').checked;
|
||||
const id = (n) => `${n.label}|${n.key}`;
|
||||
@@ -3104,6 +3177,60 @@ function renderGraphMemory(x) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-finding filter: show only the selected finding's attack path
|
||||
const descBox = x.q('fdesc');
|
||||
if (filterFid) {
|
||||
const fNode = data.nodes.find(n => n.label === 'Finding' && n.key === filterFid);
|
||||
const fid = fNode ? id(fNode) : `Finding|${filterFid}`;
|
||||
// Only keep edges directly involving this finding, then walk outward
|
||||
// from connected non-Finding nodes (hosts, impacts, steps).
|
||||
// Exclude edges connecting OTHER findings to shared hosts.
|
||||
const findingEdges = edges.filter(e => eid(e.from) === fid || eid(e.to) === fid);
|
||||
const reachable = new Set([fid]);
|
||||
for (const e of findingEdges) { reachable.add(eid(e.from)); reachable.add(eid(e.to)); }
|
||||
// Walk traversal edges from those seeds, but never bridge to other findings
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const e of edges) {
|
||||
const from = eid(e.from), to = eid(e.to);
|
||||
if (from === fid || to === fid) continue; // already handled
|
||||
const fromIn = reachable.has(from), toIn = reachable.has(to);
|
||||
if (!fromIn && !toIn) continue;
|
||||
// Don't bridge to other finding nodes
|
||||
if (fromIn && !toIn && e.to.label === 'Finding') continue;
|
||||
if (toIn && !fromIn && e.from.label === 'Finding') continue;
|
||||
if (fromIn && !toIn) { reachable.add(to); changed = true; }
|
||||
if (toIn && !fromIn) { reachable.add(from); changed = true; }
|
||||
}
|
||||
}
|
||||
nodes = nodes.filter(n => reachable.has(id(n)));
|
||||
keep = new Set(nodes.map(id));
|
||||
edges = edges.filter(e => keep.has(eid(e.from)) && keep.has(eid(e.to)));
|
||||
if (fNode && descBox) {
|
||||
const f = gmFinding(x, fNode.props?.finding_id);
|
||||
const props = fNode.props || {};
|
||||
descBox.innerHTML = `<div class="fd-header">
|
||||
<span class="sev ${sevClass(props.local_severity || 'info')}">${esc(props.local_severity || 'info')}</span>
|
||||
<span class="fd-title">${esc(gmName(fNode))}</span>
|
||||
</div>
|
||||
<div class="fd-body">${props.description ? `<p>${esc(props.description)}</p>` : ''}
|
||||
${props.category ? `<p><b>Category:</b> ${esc(props.category)}</p>` : ''}
|
||||
${props.cwe ? `<p><b>CWE:</b> ${esc(Array.isArray(props.cwe) ? props.cwe.join(', ') : props.cwe)}</p>` : ''}
|
||||
${props.stage ? `<p><b>Stage:</b> ${esc(props.stage)}</p>` : ''}
|
||||
</div>
|
||||
<div class="fd-meta">
|
||||
${props.status ? `<span>Status: ${esc(props.status)}</span>` : ''}
|
||||
${f ? '<button class="btn btn-sm" data-openfinding>Open finding details</button>' : ''}
|
||||
</div>`;
|
||||
show(descBox, true);
|
||||
const openBtn = descBox.querySelector('[data-openfinding]');
|
||||
if (openBtn && f) openBtn.addEventListener('click', () => openFindingModal(f.f, f.pocs, f.runId));
|
||||
}
|
||||
} else if (descBox) {
|
||||
show(descBox, false);
|
||||
}
|
||||
|
||||
// Columns by entity kind; a long column wraps into sub-columns.
|
||||
const W = 220, H = 54, GX = 90, GY = 12, PER = 12;
|
||||
const cols = new Map();
|
||||
@@ -3451,9 +3578,16 @@ async function loadIssues() {
|
||||
if (!data.length) { body.innerHTML = '<div class="empty-state">No findings across any engagement yet.</div>'; return; }
|
||||
const rows = data.map(f => {
|
||||
const sc = sevClass(f.severity);
|
||||
return `<tr><td><span class="pill ${sc}">${esc(f.severity)}</span></td><td>${esc(f.title)}</td><td class="mono">${esc(f.target || '')}</td><td class="mono">${esc(f.run || '')}</td><td>${esc(f.status || 'open')}</td></tr>`;
|
||||
return `<tr class="issue-row" data-run="${esc(f.run || '')}" data-title="${esc(f.title || '')}" data-cwe="${esc(f.cwe || '')}" style="cursor:pointer"><td><span class="sev ${sc}">${esc(f.severity)}</span></td><td>${esc(f.title)}</td><td class="mono">${esc(f.target || '')}</td><td class="mono">${esc(f.run || '')}</td><td>${esc(f.status || 'open')}</td></tr>`;
|
||||
}).join('');
|
||||
body.innerHTML = `<table class="data-table"><thead><tr><th>Severity</th><th>Title</th><th>Target</th><th>Run</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
$$('.issue-row', body).forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const runId = row.dataset.run;
|
||||
if (!runId) return;
|
||||
openContributorFinding(runId, row.dataset.title, row.dataset.cwe);
|
||||
});
|
||||
});
|
||||
} catch (e) { body.innerHTML = `<div class="empty-state">Failed to load: ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
@@ -3478,10 +3612,11 @@ async function loadDomains() {
|
||||
try {
|
||||
const domains = await api('/api/config/domains');
|
||||
if (!domains.length) { body.innerHTML = '<div class="empty-state">No domains configured yet. Click <strong>+ Add Domain</strong> to add one.</div>'; return; }
|
||||
const typeLabel = { web: 'Web', api: 'API', llm: 'LLM', mobile: 'Mobile', other: 'Other' };
|
||||
body.innerHTML = '<div class="config-grid">' + domains.map(d => `<div class="config-card">
|
||||
<div class="config-card-title">${esc(d.domain)}</div>
|
||||
<div class="config-card-desc">${esc(d.description || 'Target domain')}</div>
|
||||
<div class="config-card-meta">${d.subdomains ? d.subdomains.length + ' subdomains' : 'No subdomains'} · ${esc(d.scope || 'in-scope')}</div>
|
||||
<div class="config-card-meta">${esc(typeLabel[d.type] || d.type || 'Web')} · ${d.subdomains ? d.subdomains.length + ' subdomains' : 'No subdomains'} · ${esc(d.scope || 'in-scope')}</div>
|
||||
<div class="config-card-actions"><button class="btn btn-danger" onclick="deleteDomain('${esc(d.id)}')">Remove</button></div>
|
||||
</div>`).join('') + '</div>';
|
||||
} catch { body.innerHTML = '<div class="empty-state">No domains configured yet. Click <strong>+ Add Domain</strong> to add one.</div>'; }
|
||||
@@ -3560,11 +3695,12 @@ async function loadSettings() {
|
||||
<div class="config-card-actions"><button class="btn btn-accent" onclick="saveSettings()">Save</button></div>
|
||||
</div>
|
||||
<div class="config-card">
|
||||
<div class="config-card-title">MCP Server</div>
|
||||
<div class="config-card-desc">Run NeuroSploit as an MCP server for Claude Code, Codex, Cursor, Windsurf and other MCP clients.</div>
|
||||
<div class="config-card-meta" style="margin-top:var(--sp-2)"><code>neurosploit mcp</code><br/>Config: <code>{"command":"neurosploit","args":["mcp"]}</code></div>
|
||||
<div class="config-card-title">LLM Provider Keys</div>
|
||||
<div class="config-card-desc">API keys for the LLMs that run engagements. Kept in server memory only, never written to disk.</div>
|
||||
<div class="config-card-actions"><button class="btn btn-accent" id="btnOpenProviderKeys">Config</button></div>
|
||||
</div>
|
||||
</div>`;
|
||||
$('#btnOpenProviderKeys')?.addEventListener('click', () => openProviderKeysModal());
|
||||
} catch { body.innerHTML = '<div class="empty-state">Failed to load settings.</div>'; }
|
||||
}
|
||||
|
||||
@@ -3628,6 +3764,115 @@ function configureIntegration(id) {
|
||||
$('#integrationModal').dataset.integId = id;
|
||||
$('#integrationModal').hidden = false;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadVault() {
|
||||
const header = $('#vaultAuthHeader');
|
||||
if (header) header.value = state.auth?.header || '';
|
||||
const credsPath = $('#vaultCredsPath');
|
||||
if (credsPath) credsPath.value = state.credsPath || '';
|
||||
const collPath = $('#vaultCollectionPath');
|
||||
if (collPath) collPath.value = state.collectionPath || '';
|
||||
renderVaultRoles();
|
||||
}
|
||||
|
||||
function renderVaultRoles() {
|
||||
const list = $('#vaultRoleList');
|
||||
if (!list) return;
|
||||
if (!state.auth) state.auth = { header: '', roles: [] };
|
||||
list.innerHTML = state.auth.roles.map((r, i) => `
|
||||
<div class="role-row">
|
||||
<input type="text" placeholder="Role name" value="${esc(r.name)}" data-ri="${i}" data-rf="name" />
|
||||
<input type="text" placeholder="Auth header for this role" value="${esc(r.header)}" data-ri="${i}" data-rf="header" />
|
||||
<button class="icon-btn" data-rdel="${i}">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
$$('input[data-ri]', list).forEach(inp => inp.addEventListener('change', () => {
|
||||
state.auth.roles[Number(inp.dataset.ri)][inp.dataset.rf] = inp.value;
|
||||
}));
|
||||
$$('[data-rdel]', list).forEach(btn => btn.addEventListener('click', () => {
|
||||
state.auth.roles.splice(Number(btn.dataset.rdel), 1);
|
||||
renderVaultRoles();
|
||||
}));
|
||||
}
|
||||
|
||||
$('#btnVaultAddRole')?.addEventListener('click', () => {
|
||||
if (!state.auth) state.auth = { header: '', roles: [] };
|
||||
state.auth.roles.push({ name: '', header: '' });
|
||||
renderVaultRoles();
|
||||
});
|
||||
|
||||
$('#btnVaultSave')?.addEventListener('click', async () => {
|
||||
state.auth.header = $('#vaultAuthHeader')?.value || '';
|
||||
state.credsPath = $('#vaultCredsPath')?.value || '';
|
||||
state.collectionPath = $('#vaultCollectionPath')?.value || '';
|
||||
try {
|
||||
await api('/api/config/vault', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auth: state.auth, credsPath: state.credsPath, collectionPath: state.collectionPath }),
|
||||
});
|
||||
toast('Vault saved', 'ok');
|
||||
} catch (e) { toast(`Failed: ${e.message}`, 'error'); }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guardrails page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadGuardrails() {
|
||||
api('/api/config/guardrails').then(cfg => {
|
||||
if (!cfg) cfg = {};
|
||||
$('#grCapToken').value = cfg.capToken || '';
|
||||
$('#grInScope').value = cfg.inScope || '';
|
||||
$('#grScopeHard').value = cfg.scopeHard || '';
|
||||
$('#grScopeExclude').value = cfg.scopeExclude || '';
|
||||
$('#grScopeObserve').value = cfg.scopeObserve || '';
|
||||
$('#grDestructive').checked = !!cfg.destructive;
|
||||
$('#grAccounts').checked = cfg.accounts !== false;
|
||||
$('#grMaxAccounts').value = cfg.maxAccounts ?? 3;
|
||||
$('#grRate').value = cfg.rate ?? 240;
|
||||
$('#grForbidden').value = cfg.forbidden || '';
|
||||
$('#grNotes').value = cfg.notes || '';
|
||||
$('#grEnv').value = cfg.environment || 'production';
|
||||
$('#grPolicy').value = cfg.policy || 'web';
|
||||
$('#grTransport').value = cfg.transport || '';
|
||||
$('#grOobDomain').value = cfg.oobDomain || '';
|
||||
$('#grOobHttp').value = cfg.oobHttp || '';
|
||||
$('#grOobDns').value = cfg.oobDns || '';
|
||||
$('#grSms').value = cfg.sms || '';
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
$('#btnGuardrailsSave')?.addEventListener('click', async () => {
|
||||
const data = {
|
||||
capToken: $('#grCapToken')?.value || '',
|
||||
inScope: $('#grInScope')?.value || '',
|
||||
scopeHard: $('#grScopeHard')?.value || '',
|
||||
scopeExclude: $('#grScopeExclude')?.value || '',
|
||||
scopeObserve: $('#grScopeObserve')?.value || '',
|
||||
destructive: $('#grDestructive')?.checked || false,
|
||||
accounts: $('#grAccounts')?.checked !== false,
|
||||
maxAccounts: Number($('#grMaxAccounts')?.value) || 3,
|
||||
rate: Number($('#grRate')?.value) || 240,
|
||||
forbidden: $('#grForbidden')?.value || '',
|
||||
notes: $('#grNotes')?.value || '',
|
||||
environment: $('#grEnv')?.value || 'production',
|
||||
policy: $('#grPolicy')?.value || 'web',
|
||||
transport: $('#grTransport')?.value || '',
|
||||
oobDomain: $('#grOobDomain')?.value || '',
|
||||
oobHttp: $('#grOobHttp')?.value || '',
|
||||
oobDns: $('#grOobDns')?.value || '',
|
||||
sms: $('#grSms')?.value || '',
|
||||
};
|
||||
try {
|
||||
await api('/api/config/guardrails', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||
toast('Guardrails saved', 'ok');
|
||||
} catch (e) { toast(`Failed: ${e.message}`, 'error'); }
|
||||
});
|
||||
|
||||
async function saveSettings() {
|
||||
const data = {
|
||||
defaultModel: $('#settingModel')?.value || '',
|
||||
@@ -3641,6 +3886,66 @@ async function saveSettings() {
|
||||
catch (e) { toast(`Failed: ${e.message}`, 'error'); }
|
||||
}
|
||||
|
||||
async function openProviderKeysModal() {
|
||||
if (!state.providers?.length) {
|
||||
try { state.providers = await api('/api/providers'); } catch { state.providers = []; }
|
||||
}
|
||||
try { state.keys = await api('/api/keys'); } catch { state.keys = []; }
|
||||
let existing = $('#providerKeysModal');
|
||||
if (!existing) {
|
||||
document.body.insertAdjacentHTML('beforeend', `<div class="modal-overlay" id="providerKeysModal" hidden>
|
||||
<div class="modal" style="max-width:620px"><div class="modal-header"><h3>LLM Provider Keys</h3><span class="modal-sub">API keys for engagements. Kept in server memory only.</span><button class="modal-close" onclick="$('#providerKeysModal').hidden=true">✕</button></div>
|
||||
<div class="modal-body" id="providerKeysModalBody" style="max-height:60vh;overflow-y:auto"></div>
|
||||
<div class="modal-footer"><button class="btn btn-accent" onclick="$('#providerKeysModal').hidden=true">Done</button></div></div></div>`);
|
||||
existing = $('#providerKeysModal');
|
||||
}
|
||||
const body = $('#providerKeysModalBody');
|
||||
body.innerHTML = state.providers.map((p) => {
|
||||
const set = state.keys.find((k) => k.provider === p.key)?.set;
|
||||
return `
|
||||
<div class="provider-row" data-prow="${esc(p.key)}">
|
||||
<span class="dot ${set ? 'set' : ''}"></span>
|
||||
<span class="p-name">${esc(p.label)}</span>
|
||||
<span class="p-kind">${p.kind === 'cli' ? 'cli+api' : 'api only'}</span>
|
||||
<input type="password" data-sprovider="${esc(p.key)}" placeholder="${set ? '•••••••• (set)' : 'paste API key'}" />
|
||||
<button class="btn btn-sm" data-ssave="${esc(p.key)}">Save</button>
|
||||
<span class="key-verify" data-sverify="${esc(p.key)}"></span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
const doSave = async (btn) => {
|
||||
const provider = btn.dataset.ssave;
|
||||
const input = body.querySelector(`input[data-sprovider="${provider}"]`);
|
||||
const vtag = body.querySelector(`[data-sverify="${provider}"]`);
|
||||
const p = state.providers.find(x => x.key === provider);
|
||||
const key = input.value.trim();
|
||||
if (!key) return;
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
if (vtag) { vtag.className = 'key-verify checking'; vtag.textContent = '· verifying…'; }
|
||||
try {
|
||||
const r = await api('/api/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, key }) });
|
||||
input.value = '';
|
||||
const v = r.verify || { ok: null, message: 'saved' };
|
||||
if (vtag) {
|
||||
vtag.className = `key-verify ${v.ok === true ? 'ok' : v.ok === false ? 'bad' : 'unknown'}`;
|
||||
vtag.textContent = v.ok === true ? '✓ valid' : v.ok === false ? '✗ ' + v.message : '• ' + v.message;
|
||||
}
|
||||
toast(v.ok === true ? `${p?.label || provider}: key valid` : v.ok === false ? `${p?.label || provider}: ${v.message}` : `${p?.label || provider}: ${v.message}`, v.ok === false ? 'error' : 'ok');
|
||||
state.keys = await api('/api/keys').catch(() => state.keys);
|
||||
const dot = btn.closest('.provider-row')?.querySelector('.dot');
|
||||
if (dot) dot.classList.add('set');
|
||||
input.placeholder = '•••••••• (set)';
|
||||
} catch (e) {
|
||||
if (vtag) { vtag.className = 'key-verify bad'; vtag.textContent = '✗ ' + e.message; }
|
||||
} finally { btn.disabled = false; btn.textContent = 'Save'; }
|
||||
};
|
||||
$$('[data-ssave]', body).forEach((btn) => btn.addEventListener('click', () => doSave(btn)));
|
||||
$$('input[data-sprovider]', body).forEach((inp) => inp.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') body.querySelector(`[data-ssave="${inp.dataset.sprovider}"]`)?.click();
|
||||
}));
|
||||
existing.hidden = false;
|
||||
}
|
||||
|
||||
// Add-item modals
|
||||
function openModal(id) { $(id).hidden = false; }
|
||||
function closeModal(id) { $(id).hidden = true; }
|
||||
@@ -3655,6 +3960,7 @@ $('#btnAddRepo')?.addEventListener('click', () => {
|
||||
$('#btnAddDomain')?.addEventListener('click', () => {
|
||||
$('#domainName').value = '';
|
||||
$('#domainDesc').value = '';
|
||||
if ($('#domainType')) $('#domainType').value = 'web';
|
||||
if ($('#domainScope')) $('#domainScope').value = 'in-scope';
|
||||
openModal('#domainModal');
|
||||
});
|
||||
@@ -3688,6 +3994,7 @@ $('#btnDomainSubmit')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/config/domains', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
domain,
|
||||
type: $('#domainType')?.value || 'web',
|
||||
description: $('#domainDesc')?.value?.trim() || '',
|
||||
scope: $('#domainScope')?.value || 'in-scope',
|
||||
})});
|
||||
|
||||
+213
-7
@@ -47,6 +47,10 @@
|
||||
<button class="sb-nav-item" data-view="domains"><span class="sb-nav-icon">◇</span> Domains</button>
|
||||
<button class="sb-nav-item" data-view="networks"><span class="sb-nav-icon">⬡</span> Networks</button>
|
||||
<div class="sb-nav-separator"></div>
|
||||
<button class="sb-nav-item" data-view="vault"><span class="sb-nav-icon">🔑</span> Vault</button>
|
||||
<button class="sb-nav-item" data-view="guardrails"><span class="sb-nav-icon">⛨</span> Guardrails</button>
|
||||
<div class="sb-nav-separator"></div>
|
||||
<button class="sb-nav-item" id="sbTerminal"><span class="sb-nav-icon">❭_</span> Terminal</button>
|
||||
<button class="sb-nav-item" data-view="integrations"><span class="sb-nav-icon">⚙</span> Integrations</button>
|
||||
<button class="sb-nav-item" data-view="settings"><span class="sb-nav-icon">☰</span> Settings</button>
|
||||
</nav>
|
||||
@@ -62,10 +66,6 @@
|
||||
|
||||
<div class="sb-bottom">
|
||||
<span class="sb-version" id="sbVersion">v4.3.2</span>
|
||||
<div class="sb-bottom-actions">
|
||||
<button class="icon-btn" id="btnOpenAuth" title="Auth & API keys">🔑</button>
|
||||
<button class="icon-btn" id="btnOpenRepl" title="Open terminal (Ctrl+`)">❭_</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -184,12 +184,201 @@
|
||||
<div class="page-body" id="networksBody"><div class="empty-state">No networks configured yet.</div></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<section class="page-view" id="vaultView" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="topbar-title">Vault</div>
|
||||
<div class="topbar-sub">Target authentication, credentials files, and API collections</div>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
</header>
|
||||
<div class="page-body" id="vaultBody">
|
||||
<div class="config-grid">
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-title">Target Authentication</div>
|
||||
<div class="config-card-desc">Credentials used so agents test as a logged-in user. Kept only for this session.</div>
|
||||
<div class="form-group">
|
||||
<label for="vaultAuthHeader">Auth header</label>
|
||||
<input id="vaultAuthHeader" type="text" placeholder="Authorization: Bearer <token> or Cookie: session=..." />
|
||||
<div class="field-help">Sent to the CLI as an ephemeral creds file.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Named roles (multi-identity access-control testing)</label>
|
||||
<div class="role-list" id="vaultRoleList"></div>
|
||||
<button class="btn btn-sm" id="btnVaultAddRole" style="align-self:flex-start;">+ Add role</button>
|
||||
<div class="field-help">Two or more roles enable IDOR/BOLA/BFLA cross-role testing.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-title">Credentials File</div>
|
||||
<div class="form-group">
|
||||
<label for="vaultCredsPath">creds.yaml path (overrides target auth above)</label>
|
||||
<input id="vaultCredsPath" type="text" placeholder="creds.yaml" />
|
||||
<div class="field-help">An explicit file on disk — see neurosploit-rs/creds.example.yaml for the schema.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vaultCollectionFile">API collection (Postman / OpenAPI / Swagger / HAR)</label>
|
||||
<input id="vaultCollectionFile" type="file" accept=".json,application/json,.har" multiple />
|
||||
<input id="vaultCollectionPath" type="text" placeholder="…or a path on disk: file.json or a directory of .json files" />
|
||||
<div class="field-help">Imports endpoints as known surface — file or directory of .json files.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-actions"><button class="btn btn-accent" id="btnVaultSave">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ GUARDRAILS ============ -->
|
||||
<section class="page-view" id="guardrailsView" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="topbar-title">Guardrails</div>
|
||||
<div class="topbar-sub">Authorization, scoping, safety controls, environment, and egress configuration</div>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
</header>
|
||||
<div class="page-body" id="guardrailsBody">
|
||||
<div class="config-grid">
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-title">Capability Token</div>
|
||||
<div class="form-group">
|
||||
<label for="grCapToken">Capability token</label>
|
||||
<input id="grCapToken" type="text" placeholder="ns-cap.v1.…" spellcheck="false" />
|
||||
<div class="field-status" id="grCapStatus"></div>
|
||||
<div class="field-help">Signed grant for this engagement: who authorized it, which hosts, which environment, until when. Verified by the harness. Mint with <code>neurosploit capability issue</code>.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grInScope">Additional authorized hosts</label>
|
||||
<input id="grInScope" type="text" placeholder="app.example.com, *.api.example.com, 10.0.0.0/24" />
|
||||
<div class="field-help">Without this the engagement is authorized against the target and nothing else.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-title">Scoping & Guardrails <span class="pill" style="font-size:10px;">enforced in code</span></div>
|
||||
<div class="config-card-desc">The hard scope is the boundary: a request whose host is not listed is refused before it is sent. Exclusions always win. A capability token still caps all of this.</div>
|
||||
<div class="form-group">
|
||||
<label for="grScopeHard">Hard scope — the allowlist</label>
|
||||
<textarea id="grScopeHard" rows="3" placeholder="one per line, or comma-separated app.example.com *.staging.example.com"></textarea>
|
||||
<div class="field-help">Exact host · <code>*.wildcard</code> · <code>CIDR</code> · <code>https://host/path</code> prefix. Empty = nothing extra enforced.</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="form-group">
|
||||
<label for="grScopeExclude">Exclusions — always win</label>
|
||||
<textarea id="grScopeExclude" rows="3" placeholder="payments.example.com admin.example.com"></textarea>
|
||||
<div class="field-help">Refused even if the allowlist would cover them.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grScopeObserve">Observe-only</label>
|
||||
<textarea id="grScopeObserve" rows="3" placeholder="cdn.example.com *.thirdparty.example.com"></textarea>
|
||||
<div class="field-help">May be looked at (recon) but never attacked.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="form-group">
|
||||
<label>State-changing methods</label>
|
||||
<div class="check-row"><input type="checkbox" id="grDestructive" /> <label for="grDestructive">Allow DELETE / PUT / PATCH</label></div>
|
||||
<div class="field-help">Off by default — a scan should not change the target's state.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Test accounts</label>
|
||||
<div class="check-row"><input type="checkbox" id="grAccounts" checked /> <label for="grAccounts">Allow account creation</label></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grMaxAccounts">Max accounts</label>
|
||||
<input class="narrow" id="grMaxAccounts" type="number" min="0" value="3" />
|
||||
<div class="field-help">0 = unlimited.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grRate">Requests / min</label>
|
||||
<input class="narrow" id="grRate" type="number" min="0" value="240" />
|
||||
<div class="field-help">Whole engagement. 0 = unlimited.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grForbidden">Forbidden payloads</label>
|
||||
<textarea id="grForbidden" rows="2" placeholder="delete from drop table rm -rf /"></textarea>
|
||||
<div class="field-help">Substrings NEVER acceptable. Extends built-in defaults.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grNotes">Notes — guidance, NOT enforced</label>
|
||||
<textarea id="grNotes" rows="2" placeholder="SOW-2026-0142; test window 02:00–06:00 UTC"></textarea>
|
||||
<div class="field-help">Context passed to agents. Prose is not a control.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-title">Environment & Policy</div>
|
||||
<div class="field-row">
|
||||
<div class="form-group">
|
||||
<label for="grEnv">Environment</label>
|
||||
<select id="grEnv">
|
||||
<option value="production" selected>production</option>
|
||||
<option value="staging">staging</option>
|
||||
<option value="development">development</option>
|
||||
<option value="lab">lab</option>
|
||||
<option value="ot-production">ot-production (ICS/SCADA)</option>
|
||||
</select>
|
||||
<div class="field-help">Scales every risk score.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grPolicy">Policy profile</label>
|
||||
<select id="grPolicy">
|
||||
<option value="web" selected>web — standard</option>
|
||||
<option value="ot">ot — read-only, paced, industrial writes blocked</option>
|
||||
</select>
|
||||
<div class="field-help">OT blocks writes, disruptive actions, fuzzing and exploit payloads over industrial protocols.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-title">Egress & Out-of-band</div>
|
||||
<div class="form-group">
|
||||
<label for="grTransport">Transport (how traffic reaches the target)</label>
|
||||
<input id="grTransport" type="text" placeholder="direct · socks5://127.0.0.1:1080 · openvpn:/path/client.ovpn" spellcheck="false" />
|
||||
<div class="field-help">An internal target with no transport is refused, not tested.</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="form-group">
|
||||
<label for="grOobDomain">Out-of-band domain</label>
|
||||
<input id="grOobDomain" type="text" placeholder="oob.yourdomain.com" spellcheck="false" />
|
||||
<div class="field-help">Without it blind SSRF/XXE/RCE can only be reported as leads.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grOobHttp">OOB HTTP listener</label>
|
||||
<input id="grOobHttp" type="text" placeholder="0.0.0.0:8080" spellcheck="false" />
|
||||
<div class="field-help">Where callbacks land.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grOobDns">OOB DNS listener</label>
|
||||
<input id="grOobDns" type="text" placeholder="0.0.0.0:5353" spellcheck="false" />
|
||||
<div class="field-help">Only if the zone is delegated here.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="grSms">Inbound SMS (OTP & rate-limit work)</label>
|
||||
<input id="grSms" type="text" placeholder="twilio:<sid>:<token>:+15550101 · webhook:https://…/inbox:+15550101" spellcheck="false" />
|
||||
<div class="field-help">A throttling claim counts delivered messages carrying distinct codes, not HTTP 200s.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="grid-column:1/-1;">
|
||||
<div class="config-card-actions"><button class="btn btn-accent" id="btnGuardrailsSave">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ INTEGRATIONS ============ -->
|
||||
<section class="page-view" id="integrationsView" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="topbar-title">Integrations</div>
|
||||
<div class="topbar-sub">Connect external services — GitHub, GitLab, Jira, Slack, CI/CD</div>
|
||||
<div class="topbar-sub">Connect external services — GitHub, GitLab, Jira, Slack, CI/CD, AWS, GCP, Azure</div>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
</header>
|
||||
@@ -201,7 +390,7 @@
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="topbar-title">Settings</div>
|
||||
<div class="topbar-sub">Console configuration, API keys, and preferences</div>
|
||||
<div class="topbar-sub">Console configuration, LLM provider keys, and preferences</div>
|
||||
</div>
|
||||
<div class="topbar-spacer"></div>
|
||||
</header>
|
||||
@@ -248,6 +437,7 @@
|
||||
<button class="mode-tile" data-mode="greybox"><span class="t">Grey-box</span><span class="d">URL + source together</span></button>
|
||||
<button class="mode-tile" data-mode="host"><span class="t">Host / Infra</span><span class="d">IP — Linux/Windows/AD</span></button>
|
||||
<button class="mode-tile" data-mode="aitest"><span class="t">AI / LLM</span><span class="d">Live AI agent or MCP endpoint</span></button>
|
||||
<button class="mode-tile" data-mode="cloud"><span class="t">Cloud</span><span class="d">AWS / GCP / Azure audit</span></button>
|
||||
</div>
|
||||
|
||||
<div class="field-group" id="fieldTargetGroup">
|
||||
@@ -262,6 +452,13 @@
|
||||
<div class="field-error" id="errRepo" hidden></div>
|
||||
<div class="field-help">Cloned automatically if it's a GitHub URL or owner/repo shorthand.</div>
|
||||
</div>
|
||||
<div class="field-group" id="fieldCloudGroup" hidden>
|
||||
<label class="field-label">Connected cloud</label>
|
||||
<select id="fieldCloudProvider" class="narrow">
|
||||
<option value="">Select a cloud provider…</option>
|
||||
</select>
|
||||
<div class="field-help">Pick a connected cloud integration from the Integrations page. Configure credentials there first.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 — Scope & Auth -->
|
||||
@@ -542,7 +739,6 @@
|
||||
<a class="btn" id="detailOpenPdf" target="_blank" hidden>⤓ PDF</a>
|
||||
<button class="btn" id="btnBuildReport" title="Regenerate this run's report from its findings">Generate report</button>
|
||||
<button class="btn" id="btnGraphRecompute" title="Recompute the attack graph with the CLI (neurosploit graph)">↻ Graph</button>
|
||||
<button class="btn" id="btnGraphPush" title="Push the attack graph to Neo4j (neurosploit graph --push; needs NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD on the server)">⇪ Neo4j</button>
|
||||
<button class="btn" id="btnRetest" title="Re-run every finding's recorded proof (neurosploit poc) and see what still reproduces">⟳ Re-test</button>
|
||||
<a class="btn" id="detailOpenAudit" target="_blank" hidden title="Every action this run took, hash-chained">Audit trail</a>
|
||||
<button class="btn btn-danger" id="btnDeleteRun" title="Permanently delete this session and its report">🗑 Delete</button>
|
||||
@@ -894,6 +1090,16 @@
|
||||
<input id="domainName" type="text" placeholder="app.example.com" spellcheck="false" />
|
||||
<div class="field-help">The target domain for black-box scanning. Subdomains will be discovered during recon.</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="domainType">Type</label>
|
||||
<select id="domainType">
|
||||
<option value="web" selected>Web application</option>
|
||||
<option value="api">API</option>
|
||||
<option value="llm">LLM endpoint</option>
|
||||
<option value="mobile">Mobile backend</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" for="domainDesc">Description (optional)</label>
|
||||
<input id="domainDesc" type="text" placeholder="Production web application" />
|
||||
|
||||
+34
-2
@@ -657,6 +657,7 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.modal-foot { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border); }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||
.modal-head .title { font-size: var(--fs-lg); font-weight: 600; }
|
||||
.modal-sub { display: block; font-size: var(--fs-xs); font-weight: 400; color: var(--text-faint); margin-top: 2px; }
|
||||
.modal-tabs { display: flex; gap: var(--sp-1); padding: 0 var(--sp-5); border-bottom: 1px solid var(--border); }
|
||||
.modal-tab { padding: var(--sp-3) var(--sp-2); font-size: var(--fs-sm); font-weight: 500; color: var(--text-faint); border-bottom: 2px solid transparent; background: none; border-top: none; border-left: none; border-right: none; }
|
||||
.modal-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
@@ -667,19 +668,32 @@ textarea { resize: vertical; min-height: 72px; }
|
||||
.field-status.ok { color: var(--sev-low-fg); }
|
||||
.field-status.bad { color: var(--sev-critical-fg); }
|
||||
|
||||
.provider-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); }
|
||||
.provider-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); position: relative; }
|
||||
.provider-row:last-child { border-bottom: none; }
|
||||
.provider-row .p-name { flex: none; width: 150px; font-size: var(--fs-sm); font-weight: 500; }
|
||||
.provider-row .p-kind { flex: none; width: 74px; font-size: var(--fs-2xs); text-transform: uppercase; color: var(--text-faint); font-family: var(--mono); }
|
||||
.provider-row input { flex: 1; font-family: var(--mono); font-size: var(--fs-sm); }
|
||||
.provider-row .p-status { flex: 1; font-size: var(--fs-2xs); color: var(--text-faint); }
|
||||
.provider-row .p-status.configured { color: var(--sev-low-fg); font-weight: 600; }
|
||||
.provider-row .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); flex: none; }
|
||||
.provider-row .dot.set { background: var(--sev-low-fg); }
|
||||
.provider-row .btn-config { flex: none; }
|
||||
.key-verify { flex: none; font-size: var(--fs-2xs); font-weight: 700; min-width: 60px; }
|
||||
.key-verify.ok { color: var(--sev-low-fg); }
|
||||
.key-verify.bad { color: var(--sev-critical-fg); }
|
||||
.key-verify.unknown { color: var(--text-faint); font-weight: 500; }
|
||||
.key-verify.checking { color: var(--text-dim); font-weight: 500; }
|
||||
|
||||
/* floating config popover for provider keys */
|
||||
.provider-popover { position: absolute; top: calc(100% + 4px); right: 0; z-index: var(--z-overlay); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: 0 8px 24px rgba(0,0,0,.12); padding: var(--sp-4); min-width: 340px; max-width: 420px; }
|
||||
.provider-popover .pop-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--sp-3); }
|
||||
.provider-popover .pop-title { font-size: var(--fs-base); font-weight: 600; }
|
||||
.provider-popover .pop-close { background: none; border: none; cursor: pointer; color: var(--text-dim); font-size: 16px; padding: 2px 6px; border-radius: var(--radius-xs); }
|
||||
.provider-popover .pop-close:hover { background: var(--surface-3); }
|
||||
.provider-popover .pop-body { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.provider-popover .pop-body input { font-family: var(--mono); font-size: var(--fs-sm); width: 100%; }
|
||||
.provider-popover .pop-actions { display: flex; gap: var(--sp-2); align-items: center; }
|
||||
.provider-popover .pop-verify { font-size: var(--fs-2xs); font-weight: 700; }
|
||||
|
||||
/* ============================================================ Terminal dock
|
||||
|
||||
Docked, not floating: it is a flex child of .main, so opening it shortens
|
||||
@@ -1103,3 +1117,21 @@ a.deliv-card:hover { border-color: var(--accent); }
|
||||
.panel-toolbar .search-wrap { flex: 1 1 260px; max-width: 420px; }
|
||||
.gm-detail header { justify-content: flex-start; }
|
||||
.gm-detail header [data-close] { margin-left: auto; }
|
||||
|
||||
/* graph split: finding list + graph view */
|
||||
.graph-split { display: flex; gap: 0; height: 100%; min-height: 540px; }
|
||||
.graph-finding-list { width: 220px; min-width: 180px; flex: none; border-right: 1px solid var(--border); overflow-y: auto; background: var(--surface); padding: 0; }
|
||||
.gfl-header { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-dim); padding: var(--sp-3) var(--sp-3) var(--sp-2); }
|
||||
.gfl-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; text-align: left; background: none; border: none; border-bottom: 1px solid var(--border); padding: var(--sp-2) var(--sp-3); font-size: var(--fs-xs); color: var(--text); cursor: pointer; line-height: 1.3; }
|
||||
.gfl-item:hover { background: var(--surface-3); }
|
||||
.gfl-item.active { background: var(--accent-soft, rgba(181,89,10,.08)); color: var(--accent); font-weight: 600; border-left: 3px solid var(--accent); }
|
||||
.gfl-item .gfl-sev { font-size: var(--fs-2xs); font-weight: 700; flex: none; padding: 1px 5px; border-radius: var(--radius-xs); }
|
||||
.gfl-item .gfl-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.graph-main { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.gm-finding-desc { border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); padding: var(--sp-3) var(--sp-4); margin: 0 0 var(--sp-2); flex: none; }
|
||||
.gm-finding-desc .fd-header { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
|
||||
.gm-finding-desc .fd-title { font-weight: 600; font-size: var(--fs-base); }
|
||||
.gm-finding-desc .fd-body { font-size: var(--fs-sm); color: var(--text-dim); line-height: 1.5; }
|
||||
.gm-finding-desc .fd-body p { margin: 4px 0; }
|
||||
.gm-finding-desc .fd-meta { display: flex; gap: var(--sp-3); margin-top: var(--sp-2); font-size: var(--fs-2xs); color: var(--text-faint); }
|
||||
@media (max-width: 760px) { .graph-finding-list { width: 160px; } }
|
||||
+48
-3
@@ -227,6 +227,30 @@ function envOverrides() {
|
||||
return env;
|
||||
}
|
||||
|
||||
async function cloudEnvOverrides(cloudProvider) {
|
||||
if (!cloudProvider) return {};
|
||||
const integs = await readConfig('integrations');
|
||||
const cfg = (integs.find(i => i.id === cloudProvider) || {}).config || {};
|
||||
const env = {};
|
||||
if (cloudProvider === 'aws') {
|
||||
if (cfg.accesskey) env.AWS_ACCESS_KEY_ID = cfg.accesskey;
|
||||
if (cfg.secretkey) env.AWS_SECRET_ACCESS_KEY = cfg.secretkey;
|
||||
if (cfg.region) env.AWS_DEFAULT_REGION = cfg.region;
|
||||
if (cfg.profile) env.AWS_PROFILE = cfg.profile;
|
||||
if (cfg.rolearn) env.AWS_ROLE_ARN = cfg.rolearn;
|
||||
} else if (cloudProvider === 'gcp') {
|
||||
if (cfg.serviceaccount) env.GOOGLE_APPLICATION_CREDENTIALS = cfg.serviceaccount;
|
||||
if (cfg.project) env.GCP_PROJECT = cfg.project;
|
||||
if (cfg.region) env.CLOUDSDK_COMPUTE_REGION = cfg.region;
|
||||
} else if (cloudProvider === 'azure') {
|
||||
if (cfg.tenantid) env.AZURE_TENANT_ID = cfg.tenantid;
|
||||
if (cfg.clientid) env.AZURE_CLIENT_ID = cfg.clientid;
|
||||
if (cfg.clientsecret) env.AZURE_CLIENT_SECRET = cfg.clientsecret;
|
||||
if (cfg.subscriptionid) env.AZURE_SUBSCRIPTION_ID = cfg.subscriptionid;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/// Build a minimal creds.yaml-compatible file (see neurosploit-rs/creds.example.yaml)
|
||||
/// from a raw auth header and/or named roles, so the CLI's --creds flag can be
|
||||
/// used to carry web-entered auth material without a real file on disk.
|
||||
@@ -1048,9 +1072,13 @@ function ingestLine(job, rawLine) {
|
||||
}
|
||||
|
||||
function buildArgs(body) {
|
||||
const mode = body.mode || 'run';
|
||||
const rawMode = body.mode || 'run';
|
||||
const mode = rawMode === 'cloud' ? 'host' : rawMode;
|
||||
const args = [mode];
|
||||
if (mode === 'run' || mode === 'host' || mode === 'aitest') {
|
||||
if (rawMode === 'cloud') {
|
||||
args.push(body.target || 'localhost');
|
||||
args.push('--cloud', body.target || 'aws');
|
||||
} else if (mode === 'run' || mode === 'host' || mode === 'aitest') {
|
||||
args.push(body.target);
|
||||
} else if (mode === 'whitebox' || mode === 'skills') {
|
||||
args.push(body.repo || body.target);
|
||||
@@ -1112,7 +1140,8 @@ async function startJob(body) {
|
||||
job.pinnedAgents = body.agents || [];
|
||||
jobs.set(id, job);
|
||||
|
||||
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides() } });
|
||||
const cloudEnv = body.cloudProvider ? await cloudEnvOverrides(body.cloudProvider) : {};
|
||||
const child = spawn(BIN, args, { cwd: ROOT, env: { ...process.env, ...envOverrides(), ...cloudEnv } });
|
||||
job.child = child;
|
||||
let buf = '';
|
||||
const onData = (chunk) => {
|
||||
@@ -2053,6 +2082,22 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendJson(res, 200, body);
|
||||
}
|
||||
|
||||
// ---- Config: vault ----
|
||||
if (req.method === 'GET' && p === '/api/config/vault') return sendJson(res, 200, await readConfigObj('vault'));
|
||||
if (req.method === 'PUT' && p === '/api/config/vault') {
|
||||
const body = await readBody(req);
|
||||
await writeConfig('vault', body);
|
||||
return sendJson(res, 200, body);
|
||||
}
|
||||
|
||||
// ---- Config: guardrails ----
|
||||
if (req.method === 'GET' && p === '/api/config/guardrails') return sendJson(res, 200, await readConfigObj('guardrails'));
|
||||
if (req.method === 'PUT' && p === '/api/config/guardrails') {
|
||||
const body = await readBody(req);
|
||||
await writeConfig('guardrails', body);
|
||||
return sendJson(res, 200, body);
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: 'not found' });
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: err.message });
|
||||
|
||||
Reference in new issue
Block a user