diff --git a/backend/routes/atlas.js b/backend/routes/atlas.js index 7230ae7..d9acbb0 100644 --- a/backend/routes/atlas.js +++ b/backend/routes/atlas.js @@ -522,6 +522,61 @@ function createAtlasRouter(db, requireAuth) { } }); + // ----------------------------------------------------------------------- + // POST /hosts/vulnerabilities + // Fetch active Ivanti vulnerabilities for multiple hosts from Atlas. + // Used by the bulk action plan modal to populate the qualys_id dropdown. + // Auth: any authenticated user + // + // Request body: { host_ids: number[] } + // Response 2xx: proxied Atlas response body + // Response 400: { error: string } — invalid host_ids + // Response 503: { error: string } — Atlas not configured + // Response 502: { error: string } — Atlas API unreachable + // ----------------------------------------------------------------------- + router.post('/hosts/vulnerabilities', requireAuth(db), async (req, res) => { + if (!isConfigured) { + return res.status(503).json({ error: 'Atlas API is not configured. Check ATLAS_API_URL, ATLAS_API_USER, and ATLAS_API_PASS environment variables.' }); + } + + const { host_ids } = req.body || {}; + + if (!Array.isArray(host_ids) || host_ids.length === 0) { + return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); + } + + for (const id of host_ids) { + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'host_ids must be a non-empty array of positive integers' }); + } + } + + try { + const result = await atlasPost('/ivanti-vulnerabilities-by-host', { host_ids }, { timeout: 30000 }); + + if (result.status >= 200 && result.status < 300) { + let body; + try { + body = JSON.parse(result.body); + } catch (e) { + body = result.body; + } + res.status(result.status).json(body); + } else { + let errorBody; + try { + errorBody = JSON.parse(result.body); + } catch (e) { + errorBody = { error: result.body }; + } + res.status(result.status).json(errorBody); + } + } catch (err) { + console.error('[Atlas] POST hosts/vulnerabilities failed:', err.message); + res.status(502).json({ error: 'Failed to reach Atlas API: ' + err.message }); + } + }); + return router; } diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js index c887294..1f8cdee 100644 --- a/frontend/src/components/pages/ReportingPage.js +++ b/frontend/src/components/pages/ReportingPage.js @@ -3846,11 +3846,11 @@ const BULK_PLAN_TYPE_COLORS = { remediation: '#0EA5E9', decommission: '#EF4444', false_positive: '#F59E0B', risk_acceptance: '#A855F7', scan_exclusion: '#64748B', }; +const NEEDS_QUALYS = new Set(['remediation', 'false_positive', 'risk_acceptance']); function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) { const [planType, setPlanType] = useState('risk_acceptance'); const [commitDate, setCommitDate] = useState(''); - const [qualysId, setQualysId] = useState(''); const [jiraVnr, setJiraVnr] = useState(''); const [archerExc, setArcherExc] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -3859,6 +3859,12 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) { const [typeOpen, setTypeOpen] = useState(false); const typeRef = useRef(null); + // Vulnerability loading state + const [vulnsLoading, setVulnsLoading] = useState(false); + const [vulnsError, setVulnsError] = useState(null); + const [availableQualys, setAvailableQualys] = useState([]); + const [selectedQualys, setSelectedQualys] = useState(new Set()); + // Close type dropdown on outside click useEffect(() => { if (!typeOpen) return; @@ -3880,29 +3886,127 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) { const hostIds = useMemo(() => hostEntries.map(h => h.hostId), [hostEntries]); + // Fetch vulnerabilities from Atlas when modal opens + useEffect(() => { + if (hostIds.length === 0) return; + let cancelled = false; + const fetchVulns = async () => { + setVulnsLoading(true); + setVulnsError(null); + try { + const res = await fetch(`${API_BASE}/atlas/hosts/vulnerabilities`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host_ids: hostIds }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to fetch vulnerabilities (${res.status})`); + } + const data = await res.json(); + if (cancelled) return; + + // Parse response — Atlas returns { host_id: { qualys_id: vulnObj, ... }, ... } + const qualysMap = new Map(); + if (data && typeof data === 'object') { + const entries = Array.isArray(data) ? data : Object.entries(data); + for (const entry of entries) { + let vulns; + if (Array.isArray(entry)) { + vulns = entry[1]; + } else if (typeof entry === 'object') { + vulns = entry; + } else continue; + + if (vulns && typeof vulns === 'object' && !Array.isArray(vulns)) { + for (const [qid, vuln] of Object.entries(vulns)) { + if (!qualysMap.has(qid)) { + qualysMap.set(qid, { + qualys_id: qid, + title: vuln?.title || vuln?.vulnerability_title || qid, + count: 1, + }); + } else { + qualysMap.get(qid).count++; + } + } + } + } + } + + const sorted = [...qualysMap.values()].sort((a, b) => b.count - a.count); + setAvailableQualys(sorted); + setSelectedQualys(new Set(sorted.map(q => q.qualys_id))); + } catch (err) { + if (!cancelled) setVulnsError(err.message); + } finally { + if (!cancelled) setVulnsLoading(false); + } + }; + fetchVulns(); + return () => { cancelled = true; }; + }, [hostIds]); + + const toggleQualys = (qid) => { + setSelectedQualys(prev => { + const next = new Set(prev); + if (next.has(qid)) next.delete(qid); else next.add(qid); + return next; + }); + }; + + const toggleAllQualys = () => { + if (selectedQualys.size === availableQualys.length) { + setSelectedQualys(new Set()); + } else { + setSelectedQualys(new Set(availableQualys.map(q => q.qualys_id))); + } + }; + const handleSubmit = async () => { if (!commitDate) { setError('Commit date is required'); return; } if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; } - const needsQualysId = ['remediation', 'false_positive', 'risk_acceptance'].includes(planType); - if (needsQualysId && !qualysId.trim()) { setError(`Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`); return; } + const needsQualys = NEEDS_QUALYS.has(planType); + if (needsQualys && selectedQualys.size === 0) { + setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`); + return; + } setSubmitting(true); setError(null); try { - const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate }; - if (qualysId.trim()) body.qualys_id = qualysId.trim(); - if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim(); - if (archerExc.trim()) body.archer_exc = archerExc.trim(); + const qualysIds = needsQualys ? [...selectedQualys] : [null]; + const results = []; - const res = await fetch(`${API_BASE}/atlas/hosts/bulk-action-plans`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || data.detail || `Failed (${res.status})`); - setResult(data); + for (const qid of qualysIds) { + const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate }; + if (qid) body.qualys_id = qid; + if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim(); + if (archerExc.trim()) body.archer_exc = archerExc.trim(); + + const res = await fetch(`${API_BASE}/atlas/hosts/bulk-action-plans`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + results.push({ qualys_id: qid, success: false, error: data.error || data.detail || `Failed (${res.status})` }); + } else { + results.push({ qualys_id: qid, success: true, data }); + } + } + + const succeeded = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success); + + if (failed.length > 0 && succeeded === 0) { + throw new Error(failed[0].error); + } + + setResult({ succeeded, failed: failed.length, total: results.length, details: results }); if (onSuccess) onSuccess(); } catch (err) { setError(err.message); @@ -3962,10 +4066,22 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
Action plans created
-
+
{hostIds.length} host{hostIds.length !== 1 ? 's' : ''} — {planType.replace(/_/g, ' ')}
+ {result.total > 1 && ( +
+ {result.succeeded} of {result.total} Qualys ID{result.total !== 1 ? 's' : ''} succeeded + {result.failed > 0 && — {result.failed} failed} +
+ )} + {result.details?.filter(d => !d.success).map((d, i) => ( +
+ {d.qualys_id}: {d.error} +
+ ))}
)}