Added NVD lookup features and optional NVD API key in .env file

This commit is contained in:
2026-02-02 10:50:38 -07:00
parent 260ae48f77
commit da109a6f8b
7 changed files with 855 additions and 11 deletions

View File

@@ -14,6 +14,7 @@ const ACTION_BADGES = {
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
};
const ENTITY_TYPES = ['auth', 'cve', 'document', 'user'];

View File

@@ -0,0 +1,509 @@
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<RefreshCw className="w-6 h-6 text-green-600" />
Sync with NVD
</h2>
<p className="text-sm text-gray-500 mt-1">Update existing CVE entries with data from the National Vulnerability Database</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1">
{/* Idle Phase */}
{phase === 'idle' && (
<div className="text-center py-8">
<p className="text-lg text-gray-700 mb-2">
{cveIds.length > 0
? <><strong>{cveIds.length}</strong> unique CVE{cveIds.length !== 1 ? 's' : ''} in database</>
: 'Loading CVE count...'}
</p>
<p className="text-sm text-gray-500 mb-6">
This will fetch data from NVD for each CVE and let you review changes before applying.
Rate-limited to stay within NVD API limits.
</p>
<button
onClick={fetchNvdData}
disabled={cveIds.length === 0}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50 flex items-center gap-2 mx-auto"
>
<RefreshCw className="w-5 h-5" />
Fetch NVD Data
</button>
</div>
)}
{/* Fetching Phase */}
{phase === 'fetching' && (
<div className="py-8">
<div className="text-center mb-6">
<Loader className="w-8 h-8 text-green-600 animate-spin mx-auto mb-3" />
<p className="text-lg text-gray-700">
Fetching CVE {fetchProgress.current} of {fetchProgress.total}
</p>
<p className="text-sm text-gray-500 font-mono mt-1">{fetchProgress.currentId}</p>
</div>
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-3 mb-4">
<div
className="bg-green-600 h-3 rounded-full transition-all duration-300"
style={{ width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
/>
</div>
<div className="text-center">
<button
onClick={cancelFetch}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Review Phase */}
{phase === 'review' && (
<div>
{/* Summary bar */}
<div className="flex flex-wrap gap-3 mb-4 p-3 bg-gray-50 rounded-lg text-sm">
<span className="font-medium">Found: <span className="text-green-700">{foundCount}</span></span>
<span>|</span>
<span>Up to date: <span className="text-gray-600">{noChangeCount}</span></span>
<span>|</span>
<span>Changes: <span className="text-blue-700">{foundCount}</span></span>
<span>|</span>
<span>Not in NVD: <span className="text-gray-400">{notFoundCount}</span></span>
<span>|</span>
<span>Errors: <span className="text-red-600">{errorCount}</span></span>
</div>
{/* Bulk controls */}
{Object.keys(descriptionChoices).length > 0 && (
<div className="flex gap-2 mb-4">
<span className="text-sm text-gray-600 self-center">Descriptions:</span>
<button
onClick={() => setBulkDescriptionChoice('keep')}
className="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100 transition-colors"
>
Keep All Existing
</button>
<button
onClick={() => setBulkDescriptionChoice('nvd')}
className="px-3 py-1 text-xs rounded border border-green-300 text-green-700 hover:bg-green-50 transition-colors"
>
Use All NVD
</button>
</div>
)}
{/* Comparison table */}
<div className="space-y-2">
{Object.entries(results).map(([cveId, r]) => {
if (r.status === 'no_change') {
return (
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
<span className="text-gray-400"></span>
<span className="font-mono font-medium text-gray-500">{cveId}</span>
<span className="text-gray-400">No changes needed</span>
</div>
);
}
if (r.status === 'not_found') {
return (
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
<span className="text-gray-400"></span>
<span className="font-mono font-medium text-gray-400">{cveId}</span>
<span className="text-gray-400 italic">Not found in NVD</span>
</div>
);
}
if (r.status === 'error') {
return (
<div key={cveId} className="flex items-center gap-3 p-3 bg-red-50 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="font-mono font-medium text-gray-700">{cveId}</span>
<span className="text-red-600">{r.error}</span>
</div>
);
}
// status === 'found' — show changes
const isExpanded = expandedDesc[cveId];
return (
<div key={cveId} className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex items-start gap-3">
<CheckCircle className="w-4 h-4 text-green-500 mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-mono font-bold text-gray-900">{cveId}</span>
{r.sevChanged && (
<span className="text-xs">
Severity: <span className="text-red-600">{r.current.severity}</span>
{' → '}
<span className="text-green-700">{r.nvd.severity}</span>
</span>
)}
{r.dateChanged && (
<span className="text-xs">
Date: <span className="text-red-600">{r.current.published_date || '(none)'}</span>
{' → '}
<span className="text-green-700">{r.nvd.published_date}</span>
</span>
)}
</div>
{r.descChanged && (
<div className="mt-2">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-600">Description:</span>
<button
onClick={() => setExpandedDesc(prev => ({ ...prev, [cveId]: !prev[cveId] }))}
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
>
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{isExpanded ? 'Collapse' : 'Expand'}
</button>
</div>
{isExpanded ? (
<div className="space-y-2 text-xs">
<div className="p-2 bg-red-50 rounded border border-red-200">
<span className="font-medium text-red-700">Current: </span>
<span className="text-gray-700">{r.current.description || '(empty)'}</span>
</div>
<div className="p-2 bg-green-50 rounded border border-green-200">
<span className="font-medium text-green-700">NVD: </span>
<span className="text-gray-700">{r.nvd.description}</span>
</div>
</div>
) : (
<p className="text-xs text-gray-500">{truncate(r.nvd.description)}</p>
)}
{/* Description choice */}
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name={`desc-${cveId}`}
checked={descriptionChoices[cveId] === 'keep'}
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'keep' }))}
className="text-blue-600"
/>
Keep existing
</label>
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name={`desc-${cveId}`}
checked={descriptionChoices[cveId] === 'nvd'}
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'nvd' }))}
className="text-green-600"
/>
Use NVD
</label>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Applying Phase */}
{phase === 'applying' && (
<div className="text-center py-12">
<Loader className="w-10 h-10 text-green-600 animate-spin mx-auto mb-4" />
<p className="text-lg text-gray-700">Applying changes...</p>
</div>
)}
{/* Done Phase */}
{phase === 'done' && applyResult && (
<div className="text-center py-8">
{applyResult.error ? (
<>
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-lg text-red-700 font-medium mb-2">Sync failed</p>
<p className="text-sm text-gray-600">{applyResult.error}</p>
</>
) : (
<>
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<p className="text-lg text-green-700 font-medium mb-2">Sync complete</p>
<p className="text-sm text-gray-600">
{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated
</p>
{applyResult.errors && applyResult.errors.length > 0 && (
<p className="text-sm text-amber-600 mt-2">
{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred
</p>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
{phase === 'review' && (
<>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={applyChanges}
disabled={getChangesCount() === 0}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50"
>
Apply {getChangesCount()} Change{getChangesCount() !== 1 ? 's' : ''}
</button>
</>
)}
{phase === 'done' && (
<button
onClick={onClose}
className="px-6 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
>
Close
</button>
)}
</div>
</div>
</div>
);
}