diff --git a/web/public/app.js b/web/public/app.js
index 1512a24..a00b702 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -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 = 'Select a cloud provider… ';
+ 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 += `${c.label}${on ? ' (connected)' : ' (not configured)'} `;
+ }
+ } 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) {
Nothing pending — every chain is confirmed, or there is no multi-step path yet.
-
-
-
-
No attack graph yet — it is written at every graph checkpoint of a run, or press ↻ Graph on a finished run.
+
+
+
+ Overview
+
+
+
+
+
+
+
No attack graph yet — it is written at every graph checkpoint of a run, or press ↻ Graph on a finished run.
+
+
@@ -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 = `
+
Overview (${findings.length})
+ ${findings.map(f => {
+ const sev = f.props?.local_severity || 'info';
+ return `
+ ${esc(sev.charAt(0).toUpperCase())}
+ ${esc(gmName(f))}
+ `;
+ }).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]) => `
${esc(l)} `).join('') +
'
confirmed needs validation hypothesis ';
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 = `
+
${props.description ? `
${esc(props.description)}
` : ''}
+ ${props.category ? `
Category: ${esc(props.category)}
` : ''}
+ ${props.cwe ? `
CWE: ${esc(Array.isArray(props.cwe) ? props.cwe.join(', ') : props.cwe)}
` : ''}
+ ${props.stage ? `
Stage: ${esc(props.stage)}
` : ''}
+
+
+ ${props.status ? `Status: ${esc(props.status)} ` : ''}
+ ${f ? 'Open finding details ' : ''}
+
`;
+ 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 = '
No findings across any engagement yet.
'; return; }
const rows = data.map(f => {
const sc = sevClass(f.severity);
- return `
${esc(f.severity)} ${esc(f.title)} ${esc(f.target || '')} ${esc(f.run || '')} ${esc(f.status || 'open')} `;
+ return `
${esc(f.severity)} ${esc(f.title)} ${esc(f.target || '')} ${esc(f.run || '')} ${esc(f.status || 'open')} `;
}).join('');
body.innerHTML = `
Severity Title Target Run Status ${rows}
`;
+ $$('.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 = `
Failed to load: ${esc(e.message)}
`; }
}
@@ -3478,10 +3612,11 @@ async function loadDomains() {
try {
const domains = await api('/api/config/domains');
if (!domains.length) { body.innerHTML = '
No domains configured yet. Click + Add Domain to add one.
'; return; }
+ const typeLabel = { web: 'Web', api: 'API', llm: 'LLM', mobile: 'Mobile', other: 'Other' };
body.innerHTML = '
' + domains.map(d => `
${esc(d.domain)}
${esc(d.description || 'Target domain')}
-
${d.subdomains ? d.subdomains.length + ' subdomains' : 'No subdomains'} · ${esc(d.scope || 'in-scope')}
+
${esc(typeLabel[d.type] || d.type || 'Web')} · ${d.subdomains ? d.subdomains.length + ' subdomains' : 'No subdomains'} · ${esc(d.scope || 'in-scope')}
Remove
`).join('') + '
';
} catch { body.innerHTML = '
No domains configured yet. Click + Add Domain to add one.
'; }
@@ -3560,11 +3695,12 @@ async function loadSettings() {
Save
-
MCP Server
-
Run NeuroSploit as an MCP server for Claude Code, Codex, Cursor, Windsurf and other MCP clients.
-
neurosploit mcp Config: {"command":"neurosploit","args":["mcp"]}
+
LLM Provider Keys
+
API keys for the LLMs that run engagements. Kept in server memory only, never written to disk.
+
Config
`;
+ $('#btnOpenProviderKeys')?.addEventListener('click', () => openProviderKeysModal());
} catch { body.innerHTML = 'Failed to load settings.
'; }
}
@@ -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) => `
+
+
+
+ ✕
+
+ `).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', ``);
+ existing = $('#providerKeysModal');
+ }
+ const body = $('#providerKeysModalBody');
+ body.innerHTML = state.providers.map((p) => {
+ const set = state.keys.find((k) => k.provider === p.key)?.set;
+ return `
+
+
+ ${esc(p.label)}
+ ${p.kind === 'cli' ? 'cli+api' : 'api only'}
+
+ Save
+
+
+ `;
+ }).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',
})});
diff --git a/web/public/index.html b/web/public/index.html
index e11af3c..21c3d2c 100644
--- a/web/public/index.html
+++ b/web/public/index.html
@@ -47,6 +47,10 @@
◇ Domains
⬡ Networks
+ 🔑 Vault
+ ⛨ Guardrails
+
+ ❭_ Terminal
⚙ Integrations
☰ Settings
@@ -62,10 +66,6 @@
@@ -184,12 +184,201 @@
No networks configured yet.
+
+
+
+
+
+
+
Target Authentication
+
Credentials used so agents test as a logged-in user. Kept only for this session.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scoping & Guardrails enforced in code
+
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.
+
+
+
+
+
+
+
+
+
Environment & Policy
+
+
+
+
+
Egress & Out-of-band
+
+
+
+
+
+
+
+
+
+
Integrations
-
Connect external services — GitHub, GitLab, Jira, Slack, CI/CD
+
Connect external services — GitHub, GitLab, Jira, Slack, CI/CD, AWS, GCP, Azure
@@ -201,7 +390,7 @@
@@ -248,6 +437,7 @@
Grey-box URL + source together
Host / Infra IP — Linux/Windows/AD
AI / LLM Live AI agent or MCP endpoint
+ Cloud AWS / GCP / Azure audit
@@ -262,6 +452,13 @@
Cloned automatically if it's a GitHub URL or owner/repo shorthand.
+
+
Connected cloud
+
+ Select a cloud provider…
+
+
Pick a connected cloud integration from the Integrations page. Configure credentials there first.
+
@@ -542,7 +739,6 @@
⤓ PDF
Generate report
↻ Graph
- ⇪ Neo4j
⟳ Re-test
Audit trail
🗑 Delete
@@ -894,6 +1090,16 @@
The target domain for black-box scanning. Subdomains will be discovered during recon.
+
+ Type
+
+ Web application
+ API
+ LLM endpoint
+ Mobile backend
+ Other
+
+
Description (optional)
diff --git a/web/public/style.css b/web/public/style.css
index c3f4f40..dc33af6 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -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; } }
diff --git a/web/server.js b/web/server.js
index 74f23b4..749ef9d 100644
--- a/web/server.js
+++ b/web/server.js
@@ -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 });