Added NVD lookup features and optional NVD API key in .env file
This commit is contained in:
84
.gitea/issue_template/enhancement.yaml
Normal file
84
.gitea/issue_template/enhancement.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Enhancement
|
||||
about: Suggest an improvement to an existing feature or functionality
|
||||
title: "[Enhancement] "
|
||||
labels:
|
||||
- kind/enhancement
|
||||
- status/triage
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest an improvement! This template is for enhancements to **existing** features. If you'd like to request a brand new feature, please use the Feature Request template instead.
|
||||
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
label: Current Behavior
|
||||
description: Describe how the existing feature currently works.
|
||||
placeholder: "Currently, when I do X, it works like..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-improvement
|
||||
attributes:
|
||||
label: Proposed Improvement
|
||||
description: Describe how you'd like the existing feature to be improved.
|
||||
placeholder: "I'd like it to also do Y, or behave differently by..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Why would this improvement be valuable? What problem does it solve?
|
||||
placeholder: "This would help because..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area of the Application
|
||||
description: Which part of the application does this enhancement relate to?
|
||||
options:
|
||||
- Dashboard / CVE List
|
||||
- CVE Details
|
||||
- Document Management
|
||||
- User Management
|
||||
- Authentication
|
||||
- Audit Logging
|
||||
- API
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this enhancement to your workflow?
|
||||
options:
|
||||
- Nice to have
|
||||
- Important
|
||||
- Critical
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives or Workarounds
|
||||
description: Are there any current workarounds or alternative approaches you've considered?
|
||||
placeholder: "Currently I work around this by..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, or mockups about the enhancement here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -2,3 +2,7 @@
|
||||
PORT=3001
|
||||
API_HOST=localhost
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||
NVD_API_KEY=
|
||||
|
||||
94
backend/routes/nvdLookup.js
Normal file
94
backend/routes/nvdLookup.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// NVD CVE Lookup Routes
|
||||
const express = require('express');
|
||||
|
||||
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||
|
||||
function createNvdLookupRouter(db, requireAuth) {
|
||||
const router = express.Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(requireAuth(db));
|
||||
|
||||
// Lookup CVE details from NVD API 2.0
|
||||
router.get('/lookup/:cveId', async (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
|
||||
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||
}
|
||||
|
||||
const url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(cveId)}`;
|
||||
const headers = {};
|
||||
if (process.env.NVD_API_KEY) {
|
||||
headers['apiKey'] = process.env.NVD_API_KEY;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return res.status(429).json({ error: 'NVD API rate limit exceeded. Try again later.' });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({ error: `NVD API returned status ${response.status}.` });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
|
||||
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||
}
|
||||
|
||||
const vuln = data.vulnerabilities[0].cve;
|
||||
|
||||
// Extract English description
|
||||
const descriptionEntry = vuln.descriptions?.find(d => d.lang === 'en');
|
||||
const description = descriptionEntry ? descriptionEntry.value : '';
|
||||
|
||||
// Extract severity with cascade: CVSS v3.1 → v3.0 → v2.0
|
||||
let severity = null;
|
||||
const metrics = vuln.metrics || {};
|
||||
|
||||
if (metrics.cvssMetricV31 && metrics.cvssMetricV31.length > 0) {
|
||||
severity = metrics.cvssMetricV31[0].cvssData?.baseSeverity;
|
||||
} else if (metrics.cvssMetricV30 && metrics.cvssMetricV30.length > 0) {
|
||||
severity = metrics.cvssMetricV30[0].cvssData?.baseSeverity;
|
||||
} else if (metrics.cvssMetricV2 && metrics.cvssMetricV2.length > 0) {
|
||||
severity = metrics.cvssMetricV2[0].baseSeverity;
|
||||
}
|
||||
|
||||
// Map NVD severity strings to app levels
|
||||
const severityMap = {
|
||||
'CRITICAL': 'Critical',
|
||||
'HIGH': 'High',
|
||||
'MEDIUM': 'Medium',
|
||||
'LOW': 'Low'
|
||||
};
|
||||
severity = severity ? (severityMap[severity.toUpperCase()] || 'Medium') : 'Medium';
|
||||
|
||||
// Extract published date (YYYY-MM-DD)
|
||||
const publishedRaw = vuln.published;
|
||||
const published_date = publishedRaw ? publishedRaw.split('T')[0] : '';
|
||||
|
||||
res.json({ description, severity, published_date });
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
return res.status(504).json({ error: 'NVD API request timed out.' });
|
||||
}
|
||||
console.error('NVD lookup error:', err);
|
||||
res.status(502).json({ error: 'Failed to reach NVD API.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createNvdLookupRouter;
|
||||
@@ -17,6 +17,7 @@ const createAuthRouter = require('./routes/auth');
|
||||
const createUsersRouter = require('./routes/users');
|
||||
const createAuditLogRouter = require('./routes/auditLog');
|
||||
const logAudit = require('./helpers/auditLog');
|
||||
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -56,6 +57,9 @@ app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit))
|
||||
// Audit log routes (admin only)
|
||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
||||
|
||||
// NVD lookup routes (authenticated users)
|
||||
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
||||
|
||||
// Simple storage - upload to temp directory first
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
@@ -125,6 +129,14 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get distinct CVE IDs for NVD sync (authenticated users)
|
||||
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
||||
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
||||
if (err) return res.status(500).json({ error: err.message });
|
||||
res.json(rows.map(r => r.cve_id));
|
||||
});
|
||||
});
|
||||
|
||||
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
||||
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||
const { cveId } = req.params;
|
||||
@@ -262,6 +274,73 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk sync CVE data from NVD (editor or admin)
|
||||
app.post('/api/cves/nvd-sync', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||
const { updates } = req.body;
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No updates provided' });
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
const errors = [];
|
||||
let completed = 0;
|
||||
|
||||
db.serialize(() => {
|
||||
updates.forEach((entry) => {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (entry.description !== null && entry.description !== undefined) {
|
||||
fields.push('description = ?');
|
||||
values.push(entry.description);
|
||||
}
|
||||
if (entry.severity !== null && entry.severity !== undefined) {
|
||||
fields.push('severity = ?');
|
||||
values.push(entry.severity);
|
||||
}
|
||||
if (entry.published_date !== null && entry.published_date !== undefined) {
|
||||
fields.push('published_date = ?');
|
||||
values.push(entry.published_date);
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
completed++;
|
||||
if (completed === updates.length) sendResponse();
|
||||
return;
|
||||
}
|
||||
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||
values.push(entry.cve_id);
|
||||
|
||||
db.run(
|
||||
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
|
||||
values,
|
||||
function(err) {
|
||||
if (err) {
|
||||
errors.push({ cve_id: entry.cve_id, error: err.message });
|
||||
} else {
|
||||
updated += this.changes;
|
||||
}
|
||||
completed++;
|
||||
if (completed === updates.length) sendResponse();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function sendResponse() {
|
||||
logAudit(db, {
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
action: 'cve_nvd_sync',
|
||||
entityType: 'cve',
|
||||
entityId: null,
|
||||
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
||||
ipAddress: req.ip
|
||||
});
|
||||
const result = { message: 'NVD sync completed', updated };
|
||||
if (errors.length > 0) result.errors = errors;
|
||||
res.json(result);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== DOCUMENT ENDPOINTS ==========
|
||||
|
||||
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus } from 'lucide-react';
|
||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw } from 'lucide-react';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import UserMenu from './components/UserMenu';
|
||||
import UserManagement from './components/UserManagement';
|
||||
import AuditLog from './components/AuditLog';
|
||||
import NvdSyncModal from './components/NvdSyncModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
||||
@@ -29,6 +30,7 @@ export default function App() {
|
||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||
const [newCVE, setNewCVE] = useState({
|
||||
cve_id: '',
|
||||
vendor: '',
|
||||
@@ -37,6 +39,42 @@ export default function App() {
|
||||
published_date: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const [nvdLoading, setNvdLoading] = useState(false);
|
||||
const [nvdError, setNvdError] = useState(null);
|
||||
const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
|
||||
|
||||
const lookupNVD = async (cveId) => {
|
||||
const trimmed = cveId.trim();
|
||||
if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
|
||||
|
||||
setNvdLoading(true);
|
||||
setNvdError(null);
|
||||
setNvdAutoFilled(false);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'NVD lookup failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setNewCVE(prev => ({
|
||||
...prev,
|
||||
description: prev.description || data.description,
|
||||
severity: data.severity,
|
||||
published_date: data.published_date || prev.published_date
|
||||
}));
|
||||
setNvdAutoFilled(true);
|
||||
} catch (err) {
|
||||
setNvdError(err.message);
|
||||
} finally {
|
||||
setNvdLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCVEs = async () => {
|
||||
setLoading(true);
|
||||
@@ -163,6 +201,9 @@ export default function App() {
|
||||
description: '',
|
||||
published_date: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setNvdLoading(false);
|
||||
setNvdError(null);
|
||||
setNvdAutoFilled(false);
|
||||
fetchCVEs();
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
@@ -297,6 +338,15 @@ export default function App() {
|
||||
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowNvdSync(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Sync with NVD
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowAddCVE(true)}
|
||||
@@ -320,6 +370,11 @@ export default function App() {
|
||||
<AuditLog onClose={() => setShowAuditLog(false)} />
|
||||
)}
|
||||
|
||||
{/* NVD Sync Modal */}
|
||||
{showNvdSync && (
|
||||
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||
)}
|
||||
|
||||
{/* Add CVE Modal */}
|
||||
{showAddCVE && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
@@ -328,7 +383,7 @@ export default function App() {
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Add CVE Entry</h2>
|
||||
<button
|
||||
onClick={() => setShowAddCVE(false)}
|
||||
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<XCircle className="w-6 h-6" />
|
||||
@@ -347,15 +402,33 @@ export default function App() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
CVE ID *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="CVE-2024-1234"
|
||||
value={newCVE.cve_id}
|
||||
onChange={(e) => setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()})}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="CVE-2024-1234"
|
||||
value={newCVE.cve_id}
|
||||
onChange={(e) => { setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()}); setNvdAutoFilled(false); setNvdError(null); }}
|
||||
onBlur={(e) => lookupNVD(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
/>
|
||||
{nvdLoading && (
|
||||
<Loader className="absolute right-3 top-2.5 w-5 h-5 text-[#0476D9] animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Can be the same as existing CVE if adding another vendor</p>
|
||||
{nvdAutoFilled && (
|
||||
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Auto-filled from NVD
|
||||
</p>
|
||||
)}
|
||||
{nvdError && (
|
||||
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{nvdError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -425,7 +498,7 @@ export default function App() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddCVE(false)}
|
||||
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -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'];
|
||||
|
||||
509
frontend/src/components/NvdSyncModal.js
Normal file
509
frontend/src/components/NvdSyncModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user