diff --git a/web/public/app.js b/web/public/app.js index 5d90e0a..79f8931 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -3791,12 +3791,27 @@ function configureIntegration(id) { const defs = fields[id] || []; const title = { github: 'GitHub', gitlab: 'GitLab', jira: 'Jira', slack: 'Slack', mcp: 'MCP Server', aws: 'AWS', gcp: 'GCP', azure: 'Azure' }[id] || id; $('#integModalTitle').textContent = `Configure ${title}`; - $('#integModalBody').innerHTML = defs.map(f => - `
- - -
` - ).join(''); + api('/api/config/integrations').then(integs => { + const existing = (integs || []).find(i => i.id === id); + const cfg = existing?.config || {}; + const v = existing?.validation; + $('#integModalBody').innerHTML = defs.map(f => { + const key = f.id.replace('integ', '').toLowerCase(); + const val = cfg[key] || ''; + return `
+ + +
`; + }).join('') + (v ? `
${v.ok ? '✓' : '✗'} ${esc(v.message)}
` : ''); + $('#btnIntegSubmit').textContent = 'Save & Validate'; + }).catch(() => { + $('#integModalBody').innerHTML = defs.map(f => + `
+ + +
` + ).join(''); + }); $('#integrationModal').dataset.integId = id; $('#integrationModal').hidden = false; } @@ -4064,15 +4079,35 @@ $('#btnNetworkSubmit')?.addEventListener('click', async () => { $('#btnIntegSubmit')?.addEventListener('click', async () => { const id = $('#integrationModal').dataset.integId; if (!id) return; + const btn = $('#btnIntegSubmit'); const inputs = $('#integModalBody').querySelectorAll('input'); const config = {}; inputs.forEach(inp => { config[inp.id.replace('integ', '').toLowerCase()] = inp.value.trim(); }); + btn.disabled = true; btn.textContent = 'Validating…'; + let statusEl = $('#integModalBody').querySelector('.integ-validation'); + if (!statusEl) { + $('#integModalBody').insertAdjacentHTML('beforeend', '
'); + statusEl = $('#integModalBody').querySelector('.integ-validation'); + } + statusEl.className = 'integ-validation checking'; + statusEl.textContent = 'Verifying credentials…'; try { - await api(`/api/config/integrations/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, config }) }); - closeModal('#integrationModal'); - toast(`${id} integration saved`, 'ok'); + const result = await api(`/api/config/integrations/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, config }) }); + const v = result.validation || { ok: true, message: 'saved' }; + statusEl.className = `integ-validation ${v.ok ? 'ok' : 'bad'}`; + statusEl.textContent = v.ok ? `✓ ${v.message}` : `✗ ${v.message}`; + if (v.ok) { + toast(`${id}: ${v.message}`, 'ok'); + setTimeout(() => { closeModal('#integrationModal'); }, 1200); + } else { + toast(`${id}: ${v.message}`, 'error'); + } loadIntegrations(); - } catch (e) { toast(e.message, 'error'); } + } catch (e) { + statusEl.className = 'integ-validation bad'; + statusEl.textContent = `✗ ${e.message}`; + toast(e.message, 'error'); + } finally { btn.disabled = false; btn.textContent = 'Save & Validate'; } }); ['#repoModal', '#domainModal', '#networkModal', '#integrationModal'].forEach(sel => { diff --git a/web/public/index.html b/web/public/index.html index 21c3d2c..38173d1 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -1164,7 +1164,7 @@ diff --git a/web/public/style.css b/web/public/style.css index 14cca00..fe4b569 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -682,6 +682,11 @@ textarea { resize: vertical; min-height: 72px; } .key-verify.unknown { color: var(--text-faint); font-weight: 500; } .key-verify.checking { color: var(--text-dim); font-weight: 500; } +.integ-validation { margin-top: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm); font-size: var(--fs-sm); font-weight: 600; } +.integ-validation.ok { color: var(--sev-low-fg); background: rgba(46,204,113,.08); border: 1px solid rgba(46,204,113,.2); } +.integ-validation.bad { color: var(--sev-critical-fg); background: rgba(231,76,60,.08); border: 1px solid rgba(231,76,60,.2); } +.integ-validation.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); } diff --git a/web/server.js b/web/server.js index 749ef9d..aea12d7 100644 --- a/web/server.js +++ b/web/server.js @@ -251,6 +251,113 @@ async function cloudEnvOverrides(cloudProvider) { return env; } +async function validateIntegration(id, cfg) { + const https = require('https'); + const http = require('http'); + const fetch = (url, opts = {}) => new Promise((resolve, reject) => { + const mod = url.startsWith('https') ? https : http; + const req = mod.request(url, { method: 'GET', timeout: 8000, ...opts }, (res) => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', e => reject(e)); + req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); }); + req.end(opts.body || undefined); + }); + try { + if (id === 'github') { + const token = cfg.token || cfg.pat || ''; + if (!token) return { ok: false, message: 'Token is required' }; + const r = await fetch('https://api.github.com/user', { headers: { Authorization: `token ${token}`, 'User-Agent': 'NeuroSploit', Accept: 'application/json' } }); + if (r.status === 200) { const u = JSON.parse(r.body); return { ok: true, message: `Authenticated as ${u.login}` }; } + if (r.status === 401) return { ok: false, message: 'Invalid token — 401 Unauthorized' }; + return { ok: false, message: `GitHub returned ${r.status}` }; + } + if (id === 'gitlab') { + const token = cfg.token || cfg.pat || ''; + const url = cfg.url || 'https://gitlab.com'; + if (!token) return { ok: false, message: 'Token is required' }; + const r = await fetch(`${url.replace(/\/$/, '')}/api/v4/user`, { headers: { 'PRIVATE-TOKEN': token, Accept: 'application/json' } }); + if (r.status === 200) { const u = JSON.parse(r.body); return { ok: true, message: `Authenticated as ${u.username}` }; } + if (r.status === 401) return { ok: false, message: 'Invalid token — 401 Unauthorized' }; + return { ok: false, message: `GitLab returned ${r.status}` }; + } + if (id === 'jira') { + const domain = cfg.domain || cfg.url || ''; + const email = cfg.email || ''; + const token = cfg.token || cfg.apitoken || ''; + if (!domain || !email || !token) return { ok: false, message: 'Domain, email and API token are required' }; + const base = domain.startsWith('http') ? domain.replace(/\/$/, '') : `https://${domain}`; + const auth = Buffer.from(`${email}:${token}`).toString('base64'); + const r = await fetch(`${base}/rest/api/3/myself`, { headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' } }); + if (r.status === 200) { const u = JSON.parse(r.body); return { ok: true, message: `Authenticated as ${u.displayName || u.emailAddress}` }; } + if (r.status === 401) return { ok: false, message: 'Invalid credentials — 401 Unauthorized' }; + return { ok: false, message: `Jira returned ${r.status}` }; + } + if (id === 'slack') { + const token = cfg.token || cfg.bottoken || ''; + if (!token) return { ok: false, message: 'Bot token is required' }; + const r = await fetch('https://slack.com/api/auth.test', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' } }); + if (r.status === 200) { + const j = JSON.parse(r.body); + if (j.ok) return { ok: true, message: `Connected to ${j.team} as ${j.user}` }; + return { ok: false, message: `Slack: ${j.error}` }; + } + return { ok: false, message: `Slack returned ${r.status}` }; + } + if (id === 'aws') { + const key = cfg.accesskey || ''; + const secret = cfg.secretkey || ''; + if (!key && !cfg.profile) return { ok: false, message: 'Access Key or Profile is required' }; + if (key && !secret) return { ok: false, message: 'Secret Access Key is required when Access Key is set' }; + const { execSync } = require('child_process'); + const env = { ...process.env }; + if (key) { env.AWS_ACCESS_KEY_ID = key; env.AWS_SECRET_ACCESS_KEY = secret; } + if (cfg.region) env.AWS_DEFAULT_REGION = cfg.region; + if (cfg.profile) env.AWS_PROFILE = cfg.profile; + try { + const out = execSync('aws sts get-caller-identity --output json 2>&1', { env, timeout: 10000, encoding: 'utf8' }); + const j = JSON.parse(out); + return { ok: true, message: `Account ${j.Account} — ${j.Arn}` }; + } catch (e) { + const raw = (e.stdout || e.stderr || e.message || '').trim(); + const msg = raw.split('\n').pop(); + return { ok: false, message: msg || 'AWS CLI validation failed' }; + } + } + if (id === 'gcp') { + const sa = cfg.serviceaccount || ''; + const project = cfg.project || ''; + if (!sa && !project) return { ok: false, message: 'Service Account key path or Project ID is required' }; + if (sa) { + const fs = require('fs'); + if (!fs.existsSync(sa)) return { ok: false, message: `Service Account key file not found: ${sa}` }; + try { const j = JSON.parse(fs.readFileSync(sa, 'utf8')); return { ok: true, message: `SA: ${j.client_email || 'valid JSON key'}` }; } + catch { return { ok: false, message: 'Service Account key file is not valid JSON' }; } + } + return { ok: true, message: `Project: ${project} (credentials not verified)` }; + } + if (id === 'azure') { + const tenant = cfg.tenantid || ''; + const client = cfg.clientid || ''; + const secret = cfg.clientsecret || ''; + if (!tenant || !client || !secret) return { ok: false, message: 'Tenant ID, Client ID and Client Secret are all required' }; + const body = `grant_type=client_credentials&client_id=${encodeURIComponent(client)}&client_secret=${encodeURIComponent(secret)}&scope=https%3A%2F%2Fmanagement.azure.com%2F.default`; + const r = await fetch(`https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }); + if (r.status === 200) { return { ok: true, message: 'Token acquired — credentials valid' }; } + try { const j = JSON.parse(r.body); return { ok: false, message: j.error_description?.split('\r\n')[0] || j.error || `Azure returned ${r.status}` }; } + catch { return { ok: false, message: `Azure returned ${r.status}` }; } + } + if (id === 'mcp') { + return { ok: true, message: 'MCP config saved' }; + } + return { ok: true, message: 'Saved (no validation available)' }; + } catch (e) { + return { ok: false, message: e.message || 'Validation failed' }; + } +} + /// 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. @@ -2065,13 +2172,16 @@ const server = http.createServer(async (req, res) => { m = p.match(/^\/api\/config\/integrations\/([^/]+)$/); if (req.method === 'PUT' && m) { const body = await readBody(req); - let integs = await readConfig('integrations'); const id = decodeURIComponent(m[1]); + const cfg = body.config || {}; + const validation = await validateIntegration(id, cfg); + let integs = await readConfig('integrations'); const idx = integs.findIndex(i => i.id === id); - if (idx >= 0) integs[idx] = { ...integs[idx], ...body }; - else integs.push({ id, ...body }); + const entry = { id, ...body, validation }; + if (idx >= 0) integs[idx] = { ...integs[idx], ...entry }; + else integs.push(entry); await writeConfig('integrations', integs); - return sendJson(res, 200, integs.find(i => i.id === id)); + return sendJson(res, 200, { ...integs.find(i => i.id === id), validation }); } // ---- Config: settings ----