Add multi-select qualys_id picker to bulk Atlas action plan modal with auto-fetch from Atlas API

This commit is contained in:
root
2026-04-24 22:07:55 +00:00
parent 8da62f0f14
commit 06c6821d85
2 changed files with 280 additions and 26 deletions

View File

@@ -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; return router;
} }

View File

@@ -3846,11 +3846,11 @@ const BULK_PLAN_TYPE_COLORS = {
remediation: '#0EA5E9', decommission: '#EF4444', false_positive: '#F59E0B', remediation: '#0EA5E9', decommission: '#EF4444', false_positive: '#F59E0B',
risk_acceptance: '#A855F7', scan_exclusion: '#64748B', risk_acceptance: '#A855F7', scan_exclusion: '#64748B',
}; };
const NEEDS_QUALYS = new Set(['remediation', 'false_positive', 'risk_acceptance']);
function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) { function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
const [planType, setPlanType] = useState('risk_acceptance'); const [planType, setPlanType] = useState('risk_acceptance');
const [commitDate, setCommitDate] = useState(''); const [commitDate, setCommitDate] = useState('');
const [qualysId, setQualysId] = useState('');
const [jiraVnr, setJiraVnr] = useState(''); const [jiraVnr, setJiraVnr] = useState('');
const [archerExc, setArcherExc] = useState(''); const [archerExc, setArcherExc] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -3859,6 +3859,12 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
const [typeOpen, setTypeOpen] = useState(false); const [typeOpen, setTypeOpen] = useState(false);
const typeRef = useRef(null); 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 // Close type dropdown on outside click
useEffect(() => { useEffect(() => {
if (!typeOpen) return; if (!typeOpen) return;
@@ -3880,17 +3886,102 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
const hostIds = useMemo(() => hostEntries.map(h => h.hostId), [hostEntries]); 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 () => { const handleSubmit = async () => {
if (!commitDate) { setError('Commit date is required'); return; } if (!commitDate) { setError('Commit date is required'); return; }
if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; } if (hostIds.length === 0) { setError('No valid host IDs in selection'); return; }
const needsQualysId = ['remediation', 'false_positive', 'risk_acceptance'].includes(planType); const needsQualys = NEEDS_QUALYS.has(planType);
if (needsQualysId && !qualysId.trim()) { setError(`Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`); return; } if (needsQualys && selectedQualys.size === 0) {
setError(`At least one Qualys ID is required for ${planType.replace(/_/g, ' ')} plans`);
return;
}
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
const qualysIds = needsQualys ? [...selectedQualys] : [null];
const results = [];
for (const qid of qualysIds) {
const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate }; const body = { host_ids: hostIds, plan_type: planType, commit_date: commitDate };
if (qualysId.trim()) body.qualys_id = qualysId.trim(); if (qid) body.qualys_id = qid;
if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim(); if (jiraVnr.trim()) body.jira_vnr = jiraVnr.trim();
if (archerExc.trim()) body.archer_exc = archerExc.trim(); if (archerExc.trim()) body.archer_exc = archerExc.trim();
@@ -3901,8 +3992,21 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || data.detail || `Failed (${res.status})`); if (!res.ok) {
setResult(data); 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(); if (onSuccess) onSuccess();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -3962,10 +4066,22 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', fontWeight: 600, marginBottom: '0.5rem' }}> <div style={{ fontSize: '0.85rem', color: '#E2E8F0', fontWeight: 600, marginBottom: '0.5rem' }}>
Action plans created Action plans created
</div> </div>
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '1rem' }}> <div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.5rem' }}>
{hostIds.length} host{hostIds.length !== 1 ? 's' : ''} {planType.replace(/_/g, ' ')} {hostIds.length} host{hostIds.length !== 1 ? 's' : ''} {planType.replace(/_/g, ' ')}
</div> </div>
{result.total > 1 && (
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.5rem' }}>
{result.succeeded} of {result.total} Qualys ID{result.total !== 1 ? 's' : ''} succeeded
{result.failed > 0 && <span style={{ color: '#F87171' }}> {result.failed} failed</span>}
</div>
)}
{result.details?.filter(d => !d.success).map((d, i) => (
<div key={i} style={{ fontSize: '0.68rem', color: '#F87171', marginTop: '0.25rem' }}>
{d.qualys_id}: {d.error}
</div>
))}
<button onClick={onClose} style={{ <button onClick={onClose} style={{
marginTop: '1rem',
padding: '0.5rem 1.25rem', padding: '0.5rem 1.25rem',
background: 'rgba(14,165,233,0.15)', border: '1px solid #0EA5E9', background: 'rgba(14,165,233,0.15)', border: '1px solid #0EA5E9',
borderRadius: '0.375rem', color: '#38BDF8', borderRadius: '0.375rem', color: '#38BDF8',
@@ -4043,11 +4159,94 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
</div> </div>
{/* Optional fields — shown based on plan type */} {/* Optional fields — shown based on plan type */}
{(planType === 'remediation' || planType === 'false_positive' || planType === 'risk_acceptance') && ( {/* Qualys ID multi-select — shown for plan types that require it */}
{NEEDS_QUALYS.has(planType) && (
<div style={{ marginBottom: '0.75rem' }}> <div style={{ marginBottom: '0.75rem' }}>
<label style={labelSt}>Qualys ID <span style={{ color: '#475569', textTransform: 'none' }}>(required for {planType.replace(/_/g, ' ')})</span></label> <label style={labelSt}>
<input value={qualysId} onChange={e => setQualysId(e.target.value)} Qualys IDs
placeholder="QID-12345" style={inputSt} /> <span style={{ color: '#475569', textTransform: 'none', marginLeft: '0.3rem' }}>
({selectedQualys.size} of {availableQualys.length} selected)
</span>
</label>
{vulnsLoading && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0', color: '#475569', fontSize: '0.72rem' }}>
<Loader style={{ width: 12, height: 12, animation: 'spin 1s linear infinite' }} />
Loading vulnerabilities from Atlas...
</div>
)}
{vulnsError && (
<div style={{
padding: '0.5rem 0.75rem',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: '0.375rem', color: '#F87171', fontSize: '0.72rem',
display: 'flex', alignItems: 'center', gap: '0.4rem',
}}>
<AlertCircle style={{ width: 12, height: 12, flexShrink: 0 }} />{vulnsError}
</div>
)}
{!vulnsLoading && !vulnsError && availableQualys.length === 0 && (
<div style={{ color: '#475569', fontSize: '0.72rem', fontStyle: 'italic', padding: '0.5rem 0' }}>
No vulnerabilities found in Atlas for these hosts
</div>
)}
{!vulnsLoading && availableQualys.length > 0 && (
<div style={{
background: 'rgba(14,165,233,0.04)',
border: '1px solid rgba(14,165,233,0.15)',
borderRadius: '0.375rem',
maxHeight: '180px', overflowY: 'auto',
}}>
<div
onClick={toggleAllQualys}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.45rem 0.625rem',
borderBottom: '1px solid rgba(14,165,233,0.1)',
cursor: 'pointer', fontSize: '0.72rem', color: '#94A3B8',
}}
>
<input type="checkbox" readOnly
checked={selectedQualys.size === availableQualys.length && availableQualys.length > 0}
style={{ accentColor: '#0EA5E9', cursor: 'pointer' }}
/>
<span style={{ fontWeight: 600 }}>Select All</span>
</div>
{availableQualys.map(q => (
<div
key={q.qualys_id}
onClick={() => toggleQualys(q.qualys_id)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.4rem 0.625rem',
cursor: 'pointer', fontSize: '0.72rem',
background: selectedQualys.has(q.qualys_id) ? 'rgba(14,165,233,0.08)' : 'transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!selectedQualys.has(q.qualys_id)) e.currentTarget.style.background = 'rgba(14,165,233,0.04)'; }}
onMouseLeave={e => { if (!selectedQualys.has(q.qualys_id)) e.currentTarget.style.background = 'transparent'; }}
>
<input type="checkbox" readOnly
checked={selectedQualys.has(q.qualys_id)}
style={{ accentColor: '#0EA5E9', cursor: 'pointer', flexShrink: 0 }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<span style={{ color: '#E2E8F0', fontWeight: 600 }}>{q.qualys_id}</span>
<span style={{ color: '#475569', marginLeft: '0.4rem' }}>
({q.count} host{q.count !== 1 ? 's' : ''})
</span>
<div style={{ color: '#64748B', fontSize: '0.65rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{q.title}
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
)} )}
{planType === 'false_positive' && ( {planType === 'false_positive' && (
@@ -4078,17 +4277,17 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
)} )}
{/* Submit */} {/* Submit */}
<button onClick={handleSubmit} disabled={submitting} style={{ <button onClick={handleSubmit} disabled={submitting || vulnsLoading} style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
padding: '0.6rem 1rem', padding: '0.6rem 1rem',
background: submitting ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.15)', background: (submitting || vulnsLoading) ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.15)',
border: '1px solid #0EA5E9', borderRadius: '0.375rem', border: '1px solid #0EA5E9', borderRadius: '0.375rem',
color: submitting ? '#475569' : '#38BDF8', color: (submitting || vulnsLoading) ? '#475569' : '#38BDF8',
fontSize: '0.78rem', fontWeight: 600, cursor: submitting ? 'not-allowed' : 'pointer', fontSize: '0.78rem', fontWeight: 600, cursor: (submitting || vulnsLoading) ? 'not-allowed' : 'pointer',
textTransform: 'uppercase', letterSpacing: '0.05em', textTransform: 'uppercase', letterSpacing: '0.05em',
}}> }}>
{submitting ? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} /> : <Database style={{ width: 14, height: 14 }} />} {submitting ? <Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} /> : <Database style={{ width: 14, height: 14 }} />}
{submitting ? 'Creating...' : `Create ${hostEntries.length} Action Plan${hostEntries.length !== 1 ? 's' : ''}`} {submitting ? 'Creating...' : `Create Plans for ${hostEntries.length} Host${hostEntries.length !== 1 ? 's' : ''}${NEEDS_QUALYS.has(planType) && selectedQualys.size > 0 ? ` × ${selectedQualys.size} QID${selectedQualys.size !== 1 ? 's' : ''}` : ''}`}
</button> </button>
</div> </div>
)} )}