import React, { useState, useEffect, useRef } from 'react'; import { X, Loader, AlertCircle, CheckCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; const FETCH_DELAY_MS = 7000; // 7 seconds between requests (safe for 5 req/30s without API key) const RETRY_DELAY_MS = 35000; // Wait 35 seconds on 429 before retry export default function NvdSyncModal({ onClose, onSyncComplete }) { const [phase, setPhase] = useState('idle'); // idle, fetching, review, applying, done const [cveIds, setCveIds] = useState([]); const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0, currentId: '' }); const [results, setResults] = useState({}); // { cveId: { nvd: {...}, current: {...}, status: 'found'|'not_found'|'error'|'no_change', error: '' } } const [descriptionChoices, setDescriptionChoices] = useState({}); // { cveId: 'keep' | 'nvd' } const [applyResult, setApplyResult] = useState(null); const [expandedDesc, setExpandedDesc] = useState({}); const abortRef = useRef(null); // Fetch distinct CVE IDs on mount useEffect(() => { (async () => { try { const response = await fetch(`${API_BASE}/cves/distinct-ids`, { credentials: 'include' }); if (!response.ok) throw new Error('Failed to fetch CVE list'); const data = await response.json(); setCveIds(data); } catch (err) { console.error('Error fetching CVE IDs:', err); } })(); }, []); const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const fetchNvdData = async () => { setPhase('fetching'); const controller = new AbortController(); abortRef.current = controller; const newResults = {}; setFetchProgress({ current: 0, total: cveIds.length, currentId: '' }); // First fetch current data for all CVEs let currentData = {}; try { const response = await fetch(`${API_BASE}/cves`, { credentials: 'include', signal: controller.signal }); if (response.ok) { const allCves = await response.json(); // Group by cve_id, take first entry for description/severity/date allCves.forEach(cve => { if (!currentData[cve.cve_id]) { currentData[cve.cve_id] = { description: cve.description, severity: cve.severity, published_date: cve.published_date }; } }); } } catch (err) { if (err.name === 'AbortError') { setPhase('idle'); return; } } for (let i = 0; i < cveIds.length; i++) { if (controller.signal.aborted) break; const cveId = cveIds[i]; setFetchProgress({ current: i + 1, total: cveIds.length, currentId: cveId }); try { let response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, { credentials: 'include', signal: controller.signal }); // Handle rate limit with one retry if (response.status === 429) { await sleep(RETRY_DELAY_MS); if (controller.signal.aborted) break; response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, { credentials: 'include', signal: controller.signal }); } if (response.status === 404) { newResults[cveId] = { status: 'not_found', current: currentData[cveId] || {} }; } else if (!response.ok) { const data = await response.json().catch(() => ({})); newResults[cveId] = { status: 'error', error: data.error || `HTTP ${response.status}`, current: currentData[cveId] || {} }; } else { const nvd = await response.json(); const current = currentData[cveId] || {}; const descChanged = nvd.description && nvd.description !== current.description; const sevChanged = nvd.severity && nvd.severity !== current.severity; const dateChanged = nvd.published_date && nvd.published_date !== current.published_date; if (!descChanged && !sevChanged && !dateChanged) { newResults[cveId] = { status: 'no_change', nvd, current }; } else { newResults[cveId] = { status: 'found', nvd, current, descChanged, sevChanged, dateChanged }; } } } catch (err) { if (err.name === 'AbortError') break; newResults[cveId] = { status: 'error', error: err.message, current: currentData[cveId] || {} }; } // Update results progressively setResults({ ...newResults }); // Rate limit delay (skip after last item) if (i < cveIds.length - 1 && !controller.signal.aborted) { await sleep(FETCH_DELAY_MS); } } if (!controller.signal.aborted) { setResults({ ...newResults }); // Default all description choices to 'keep' const choices = {}; Object.entries(newResults).forEach(([id, r]) => { if (r.status === 'found' && r.descChanged) { choices[id] = 'keep'; } }); setDescriptionChoices(choices); setPhase('review'); } }; const cancelFetch = () => { if (abortRef.current) abortRef.current.abort(); setPhase('idle'); }; const setBulkDescriptionChoice = (choice) => { const newChoices = {}; Object.keys(descriptionChoices).forEach(id => { newChoices[id] = choice; }); setDescriptionChoices(newChoices); }; const getChangesCount = () => { let count = 0; Object.entries(results).forEach(([id, r]) => { if (r.status === 'found') { if (r.sevChanged || r.dateChanged || (r.descChanged && descriptionChoices[id] === 'nvd')) { count++; } } }); return count; }; const applyChanges = async () => { setPhase('applying'); const updates = []; Object.entries(results).forEach(([cveId, r]) => { if (r.status !== 'found') return; const update = { cve_id: cveId }; let hasChange = false; if (r.sevChanged) { update.severity = r.nvd.severity; hasChange = true; } if (r.dateChanged) { update.published_date = r.nvd.published_date; hasChange = true; } if (r.descChanged && descriptionChoices[cveId] === 'nvd') { update.description = r.nvd.description; hasChange = true; } if (hasChange) updates.push(update); }); if (updates.length === 0) { setApplyResult({ updated: 0, message: 'No changes to apply' }); setPhase('done'); return; } try { const response = await fetch(`${API_BASE}/cves/nvd-sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ updates }) }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'Sync failed'); } const data = await response.json(); setApplyResult(data); onSyncComplete(); } catch (err) { setApplyResult({ error: err.message }); } setPhase('done'); }; const truncate = (str, len = 120) => str && str.length > len ? str.substring(0, len) + '...' : str; // Summary counts const foundCount = Object.values(results).filter(r => r.status === 'found').length; const noChangeCount = Object.values(results).filter(r => r.status === 'no_change').length; const notFoundCount = Object.values(results).filter(r => r.status === 'not_found').length; const errorCount = Object.values(results).filter(r => r.status === 'error').length; return (
{/* Header */}

Sync with NVD

Update existing CVE entries with data from the National Vulnerability Database

{/* Body */}
{/* Idle Phase */} {phase === 'idle' && (

{cveIds.length > 0 ? <>{cveIds.length} unique CVE{cveIds.length !== 1 ? 's' : ''} in database : 'Loading CVE count...'}

This will fetch data from NVD for each CVE and let you review changes before applying. Rate-limited to stay within NVD API limits.

)} {/* Fetching Phase */} {phase === 'fetching' && (

Fetching CVE {fetchProgress.current} of {fetchProgress.total}

{fetchProgress.currentId}

{/* Progress bar */}
0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }} />
)} {/* Review Phase */} {phase === 'review' && (
{/* Summary bar */}
Found: {foundCount} | Up to date: {noChangeCount} | Changes: {foundCount} | Not in NVD: {notFoundCount} | Errors: {errorCount}
{/* Bulk controls */} {Object.keys(descriptionChoices).length > 0 && (
Descriptions:
)} {/* Comparison table */}
{Object.entries(results).map(([cveId, r]) => { if (r.status === 'no_change') { return (
{cveId} No changes needed
); } if (r.status === 'not_found') { return (
{cveId} Not found in NVD
); } if (r.status === 'error') { return (
{cveId} {r.error}
); } // status === 'found' — show changes const isExpanded = expandedDesc[cveId]; return (
{cveId} {r.sevChanged && ( Severity: {r.current.severity} {' → '} {r.nvd.severity} )} {r.dateChanged && ( Date: {r.current.published_date || '(none)'} {' → '} {r.nvd.published_date} )}
{r.descChanged && (
Description:
{isExpanded ? (
Current: {r.current.description || '(empty)'}
NVD: {r.nvd.description}
) : (

{truncate(r.nvd.description)}

)} {/* Description choice */}
)}
); })}
)} {/* Applying Phase */} {phase === 'applying' && (

Applying changes...

)} {/* Done Phase */} {phase === 'done' && applyResult && (
{applyResult.error ? ( <>

Sync failed

{applyResult.error}

) : ( <>

Sync complete

{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated

{applyResult.errors && applyResult.errors.length > 0 && (

{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred

)} )}
)}
{/* Footer */}
{phase === 'review' && ( <> )} {phase === 'done' && ( )}
); }