Detailed plan for the NVD lookup + retroactive sync feature covering stash resolution, backend endpoints, frontend NvdSyncModal component, and App.js integration. Note: claude_status.md is gitignored but has been updated locally with full session context including stash state, conflict resolution steps, and task list. Copy it manually to the offsite machine if needed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
298 lines
11 KiB
Markdown
298 lines
11 KiB
Markdown
# NVD Lookup + Retroactive Sync — Implementation Plan
|
|
|
|
## Overview
|
|
Two capabilities on `feature/nvd-lookup` branch:
|
|
1. **Auto-fill on Add CVE** (DONE, stashed) — onBlur NVD lookup fills description/severity/date in the Add CVE modal
|
|
2. **Sync with NVD** (TO DO) — bulk tool for editors/admins to retroactively update existing CVE entries from NVD, with per-CVE choice to keep or replace description
|
|
|
|
## Current State
|
|
|
|
### Git State
|
|
- **Branch:** `feature/nvd-lookup` (branched from master post-audit-merge)
|
|
- **Stash:** `stash@{0}` contains the auto-fill implementation (4 files)
|
|
- **Master** now has audit logging (merged from feature/audit on 2026-01-30)
|
|
- Offsite repo is up to date through the feature/audit merge to master
|
|
|
|
### What's in the Stash
|
|
The stash contains working NVD auto-fill code that needs to be popped and conflict-resolved before continuing:
|
|
|
|
**`backend/routes/nvdLookup.js` (NEW file)**
|
|
- Factory function: `createNvdLookupRouter(db, requireAuth)`
|
|
- `GET /lookup/:cveId` endpoint
|
|
- Validates CVE ID format (regex: `CVE-YYYY-NNNNN`)
|
|
- Calls `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=...`
|
|
- 10-second timeout via `AbortSignal.timeout(10000)`
|
|
- Optional `apiKey` header from `NVD_API_KEY` env var
|
|
- CVSS severity cascade: v3.1 → v3.0 → v2.0
|
|
- Maps NVD uppercase severity to app format (CRITICAL→Critical, etc.)
|
|
- Returns: `{ description, severity, published_date }`
|
|
|
|
**`backend/server.js` (MODIFIED)**
|
|
- Adds `const createNvdLookupRouter = require('./routes/nvdLookup');`
|
|
- Adds `app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));`
|
|
|
|
**`frontend/src/App.js` (MODIFIED)**
|
|
- New state: `nvdLoading`, `nvdError`, `nvdAutoFilled`
|
|
- New function: `lookupNVD(cveId)` — calls backend, auto-fills form fields
|
|
- CVE ID input: `onBlur` triggers lookup, `onChange` resets NVD feedback
|
|
- Spinner (Loader icon) in CVE ID field while loading
|
|
- Green "Auto-filled from NVD" with CheckCircle on success
|
|
- Amber warning with AlertCircle on errors (non-blocking)
|
|
- Description only fills if currently empty; severity + published_date always update
|
|
- NVD state resets on modal close (X, Cancel) and form submit
|
|
|
|
**`backend/.env.example` (MODIFIED)**
|
|
- Adds `NVD_API_KEY=` with comment about rate limits
|
|
|
|
### Stash Conflict Resolution
|
|
Popping the stash will conflict in `server.js` because master now has audit imports that didn't exist when the stash was created. Resolution:
|
|
|
|
The conflict is in the imports section. Keep ALL existing audit lines from master:
|
|
```js
|
|
const createAuditLogRouter = require('./routes/auditLog');
|
|
const logAudit = require('./helpers/auditLog');
|
|
```
|
|
AND add the NVD line:
|
|
```js
|
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
|
```
|
|
|
|
Similarly, keep the audit route mount and add the NVD mount after it:
|
|
```js
|
|
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
|
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
|
```
|
|
|
|
Then `git add backend/server.js` to mark resolved and `git stash drop`.
|
|
|
|
---
|
|
|
|
## Step 1: Resolve Stash + Rebase onto Master
|
|
|
|
```bash
|
|
git checkout feature/nvd-lookup
|
|
git rebase master # Get audit changes into the branch
|
|
git stash pop # Apply NVD changes (will conflict in server.js)
|
|
# Resolve conflict in server.js as described above
|
|
git add backend/server.js
|
|
git stash drop
|
|
```
|
|
|
|
Verify: `backend/routes/nvdLookup.js` exists, `server.js` has both audit AND NVD imports/mounts.
|
|
|
|
---
|
|
|
|
## Step 2: Backend — New Endpoints in `server.js`
|
|
|
|
### 2A: `GET /api/cves/distinct-ids`
|
|
Place BEFORE `GET /api/cves/check/:cveId` (to avoid route param conflict):
|
|
```js
|
|
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));
|
|
});
|
|
});
|
|
```
|
|
|
|
### 2B: `POST /api/cves/nvd-sync`
|
|
Place after the existing `PATCH /api/cves/:cveId/status`:
|
|
```js
|
|
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);
|
|
}
|
|
});
|
|
```
|
|
|
|
**How "keep existing description" works:** If the user chooses to keep the existing description, the frontend sends `description: null` for that CVE. The backend skips null fields, so the description is not overwritten. Severity and published_date are always sent (auto-update).
|
|
|
|
---
|
|
|
|
## Step 3: Frontend — New `NvdSyncModal.js` Component
|
|
|
|
**File:** `frontend/src/components/NvdSyncModal.js`
|
|
|
|
### Props
|
|
```jsx
|
|
<NvdSyncModal onClose={fn} onSyncComplete={fn} />
|
|
```
|
|
|
|
### Phase Machine
|
|
| Phase | What's shown |
|
|
|-------|-------------|
|
|
| `idle` | CVE count + "Fetch NVD Data" button |
|
|
| `fetching` | Progress bar, current CVE being fetched, cancel button |
|
|
| `review` | Comparison table with per-CVE description choice |
|
|
| `applying` | Spinner |
|
|
| `done` | Summary (X updated, Y errors) + Close button |
|
|
|
|
### Fetching Logic
|
|
- Iterate CVE IDs sequentially
|
|
- Call `GET /api/nvd/lookup/:cveId` for each
|
|
- 7-second delay between requests (safe for 5 req/30s without API key)
|
|
- On 429: wait 35 seconds, retry once
|
|
- On 404: mark as "Not found in NVD" (gray, skipped)
|
|
- On timeout/error: mark with warning (skipped)
|
|
- Support cancellation via AbortController
|
|
|
|
### Comparison Table Columns
|
|
| Column | Content |
|
|
|--------|---------|
|
|
| CVE ID | The identifier |
|
|
| Status | Icon: check=found, warning=error, dash=no changes |
|
|
| Severity | `[Current] → [NVD]` with color badges, or "No change" |
|
|
| Published Date | `Current → NVD` or "No change" |
|
|
| Description | Truncated preview with expand toggle. Current (red bg) vs NVD (green bg) when different |
|
|
| Choice | Radio: "Keep existing" (default) / "Use NVD" — only shown when descriptions differ |
|
|
|
|
### Bulk Controls
|
|
Above the table:
|
|
- Summary: `Found: N | Up to date: N | Changes: N | Not in NVD: N | Errors: N`
|
|
- Bulk toggle: "Keep All Existing" / "Use All NVD Descriptions"
|
|
|
|
Below the table:
|
|
- "Apply N Changes" button (count updates dynamically)
|
|
- "Cancel" button
|
|
|
|
### Apply Logic
|
|
Build updates array:
|
|
- For each CVE with NVD data (no error):
|
|
- Always include `severity` and `published_date` if different from current
|
|
- Include `description` only if user chose "Use NVD" — otherwise send `null`
|
|
- Skip CVEs where nothing changed
|
|
- POST to `/api/cves/nvd-sync`
|
|
- On success: call `onSyncComplete()` to refresh CVE list, then show done phase
|
|
|
|
---
|
|
|
|
## Step 4: Frontend — App.js Integration
|
|
|
|
Minimal changes following `AuditLog`/`UserManagement` pattern:
|
|
|
|
1. **Import:** Add `NvdSyncModal` and `RefreshCw` icon
|
|
2. **State:** Add `const [showNvdSync, setShowNvdSync] = useState(false);`
|
|
3. **Header button** (next to "Add CVE/Vendor", visible to editors/admins):
|
|
```jsx
|
|
{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>
|
|
)}
|
|
```
|
|
4. **Modal render** (alongside other modals):
|
|
```jsx
|
|
{showNvdSync && (
|
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: AuditLog Badge
|
|
|
|
**File:** `frontend/src/components/AuditLog.js`
|
|
|
|
Add to the `ACTION_BADGES` object:
|
|
```js
|
|
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
|
```
|
|
|
|
---
|
|
|
|
## Step 6: .env.example (already in stash)
|
|
```
|
|
# 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=
|
|
```
|
|
|
|
---
|
|
|
|
## File Summary
|
|
|
|
| File | Action | Lines Changed (est.) |
|
|
|------|--------|---------------------|
|
|
| `backend/server.js` | Modify | +40 (NVD mount + 2 new endpoints) |
|
|
| `backend/routes/nvdLookup.js` | From stash | 0 (already complete) |
|
|
| `backend/.env.example` | From stash | +3 |
|
|
| `frontend/src/components/NvdSyncModal.js` | New | ~350-400 |
|
|
| `frontend/src/App.js` | Modify | +10 (import, state, button, modal) |
|
|
| `frontend/src/components/AuditLog.js` | Modify | +1 (badge entry) |
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
1. Pop stash, resolve conflict, verify `nvdLookup.js` and server.js are correct
|
|
2. Test NVD lookup via curl: `curl -b cookie.txt http://localhost:3001/api/nvd/lookup/CVE-2024-3094`
|
|
3. Test distinct-ids: `curl -b cookie.txt http://localhost:3001/api/cves/distinct-ids`
|
|
4. Open Add CVE modal, type CVE ID, tab out → verify auto-fill works
|
|
5. Click "Sync with NVD" button → modal opens with CVE count
|
|
6. Click "Fetch NVD Data" → progress bar, rate-limited fetching
|
|
7. Review comparison table → verify diffs shown correctly
|
|
8. Toggle description choices, click "Apply" → verify database updated
|
|
9. Confirm main CVE list refreshes with new data
|
|
10. Check audit log for `cve_nvd_sync` entry
|