Files
cve-dashboard/plan.md
jramos fbdf05392a Add NVD sync implementation plan
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>
2026-01-30 15:11:30 -07:00

11 KiB

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:

const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog');

AND add the NVD line:

const createNvdLookupRouter = require('./routes/nvdLookup');

Similarly, keep the audit route mount and add the NVD mount after it:

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

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):

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:

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

<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):
{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>
)}
  1. Modal render (alongside other modals):
{showNvdSync && (
  <NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)}

Step 5: AuditLog Badge

File: frontend/src/components/AuditLog.js

Add to the ACTION_BADGES object:

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