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:
CyberSecurityUPandClaude Opus 4.6 committed 2026-10-16 17:25:24 -03:00
1 parent 84a2cca4e8
commit ecced9d713
4 files changed
+620 -30

No files matched your search

+325 -18
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; 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&#10;app.example.com&#10;*.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&#10;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&#10;*.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&#10;drop table&#10;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 &amp; 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 &amp; 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 &amp; 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
View File
@@ -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
View File
@@ -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 });