Add multi-select qualys_id picker to bulk Atlas action plan modal with auto-fetch from Atlas API
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,17 +3886,102 @@ 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 qualysIds = needsQualys ? [...selectedQualys] : [null];
|
||||
const results = [];
|
||||
|
||||
for (const qid of qualysIds) {
|
||||
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 (archerExc.trim()) body.archer_exc = archerExc.trim();
|
||||
|
||||
@@ -3901,8 +3992,21 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
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);
|
||||
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 }) {
|
||||
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
Action plans created
|
||||
</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, ' ')}
|
||||
</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={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1.25rem',
|
||||
background: 'rgba(14,165,233,0.15)', border: '1px solid #0EA5E9',
|
||||
borderRadius: '0.375rem', color: '#38BDF8',
|
||||
@@ -4043,11 +4159,94 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
</div>
|
||||
|
||||
{/* 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' }}>
|
||||
<label style={labelSt}>Qualys ID <span style={{ color: '#475569', textTransform: 'none' }}>(required for {planType.replace(/_/g, ' ')})</span></label>
|
||||
<input value={qualysId} onChange={e => setQualysId(e.target.value)}
|
||||
placeholder="QID-12345" style={inputSt} />
|
||||
<label style={labelSt}>
|
||||
Qualys IDs
|
||||
<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>
|
||||
)}
|
||||
{planType === 'false_positive' && (
|
||||
@@ -4078,17 +4277,17 @@ function BulkAtlasModal({ selectedFindings, onClose, onSuccess }) {
|
||||
)}
|
||||
|
||||
{/* 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',
|
||||
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',
|
||||
color: submitting ? '#475569' : '#38BDF8',
|
||||
fontSize: '0.78rem', fontWeight: 600, cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
color: (submitting || vulnsLoading) ? '#475569' : '#38BDF8',
|
||||
fontSize: '0.78rem', fontWeight: 600, cursor: (submitting || vulnsLoading) ? 'not-allowed' : 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
}}>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user