Compare commits
7 Commits
1a578b23c1
...
feature/nv
| Author | SHA1 | Date | |
|---|---|---|---|
| 26abd55e0f | |||
| eae4594baf | |||
| 84803a353e | |||
| d520c4ae41 | |||
| da109a6f8b | |||
| 260ae48f77 | |||
| fbdf05392a |
89
.claude/agents/backend.md
Normal file
89
.claude/agents/backend.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Backend Agent — CVE Dashboard
|
||||||
|
|
||||||
|
## Role
|
||||||
|
You are the backend specialist for the CVE Dashboard project. You manage the Express.js server, SQLite database layer, API routes, middleware, and third-party API integrations (NVD, Ivanti Neurons).
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Runtime:** Node.js v18+
|
||||||
|
- **Framework:** Express.js 4.x
|
||||||
|
- **Database:** SQLite3 (file: `backend/cve_database.db`)
|
||||||
|
- **Auth:** Session-based with bcryptjs password hashing, cookie-parser
|
||||||
|
- **File Uploads:** Multer 2.0.2 with security hardening
|
||||||
|
- **Environment:** dotenv for config management
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/server.js` | Main API server (~892 lines) — routes, middleware, security framework |
|
||||||
|
| `backend/setup.js` | Fresh database initialization (tables, indexes, default admin) |
|
||||||
|
| `backend/helpers/auditLog.js` | Fire-and-forget audit logging helper |
|
||||||
|
| `backend/middleware/auth.js` | `requireAuth(db)` and `requireRole()` middleware |
|
||||||
|
| `backend/routes/auth.js` | Login/logout/session endpoints |
|
||||||
|
| `backend/routes/users.js` | User CRUD (admin only) |
|
||||||
|
| `backend/routes/auditLog.js` | Audit log retrieval with filtering |
|
||||||
|
| `backend/routes/nvdLookup.js` | NVD API 2.0 proxy endpoint |
|
||||||
|
| `backend/.env.example` | Environment variable template |
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- **cves**: `UNIQUE(cve_id, vendor)` — multi-vendor support
|
||||||
|
- **documents**: linked by `cve_id + vendor`, tracks file metadata
|
||||||
|
- **users**: username, email, password_hash, role (admin/editor/viewer), is_active
|
||||||
|
- **sessions**: session_id, user_id, expires_at (24hr)
|
||||||
|
- **required_documents**: vendor-specific mandatory doc types
|
||||||
|
- **audit_logs**: user_id, username, action, entity_type, entity_id, details, ip_address
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `POST /api/auth/login|logout`, `GET /api/auth/me` — Authentication
|
||||||
|
- `GET|POST|PUT|DELETE /api/cves` — CVE CRUD with role enforcement
|
||||||
|
- `GET /api/cves/check/:cveId` — Quick check (multi-vendor)
|
||||||
|
- `GET /api/cves/:cveId/vendors` — Vendors for a CVE
|
||||||
|
- `POST /api/cves/:cveId/documents` — Upload documents
|
||||||
|
- `DELETE /api/documents/:id` — Admin-only document deletion
|
||||||
|
- `GET /api/vendors` — Vendor list
|
||||||
|
- `GET /api/stats` — Dashboard statistics
|
||||||
|
- `GET /api/nvd/lookup/:cveId` — NVD proxy (10s timeout, severity cascade v3.1>v3.0>v2.0)
|
||||||
|
- `POST /api/cves/nvd-sync` — Bulk NVD update with audit logging
|
||||||
|
- `GET|POST /api/audit-logs` — Audit log (admin only)
|
||||||
|
- `GET|POST|PUT|DELETE /api/users` — User management (admin only)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```
|
||||||
|
PORT=3001
|
||||||
|
API_HOST=<server-ip>
|
||||||
|
CORS_ORIGINS=http://<server-ip>:3000
|
||||||
|
SESSION_SECRET=<secret>
|
||||||
|
NVD_API_KEY=<optional>
|
||||||
|
IVANTI_API_KEY=<future>
|
||||||
|
IVANTI_CLIENT_ID=<future>
|
||||||
|
IVANTI_BASE_URL=https://platform4.risksense.com/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Security (MANDATORY)
|
||||||
|
1. **Input validation first** — Validate all inputs before any DB operation. Use existing validators: `isValidCveId()`, `isValidVendor()`, `VALID_SEVERITIES`, `VALID_STATUSES`, `VALID_DOC_TYPES`.
|
||||||
|
2. **Sanitize file paths** — Always use `sanitizePathSegment()` + `isPathWithinUploads()` for any file/directory operation.
|
||||||
|
3. **Never leak internals** — 500 responses use generic `"Internal server error."` only. Log full error server-side.
|
||||||
|
4. **Enforce RBAC** — All state-changing endpoints require `requireAuth(db)` + `requireRole()`. Viewers are read-only.
|
||||||
|
5. **Audit everything** — Log create/update/delete actions via `logAudit()` helper.
|
||||||
|
6. **File upload restrictions** — Extension allowlist + MIME validation. No executables.
|
||||||
|
7. **Parameterized queries only** — Never interpolate user input into SQL strings.
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
- Follow existing patterns in `server.js` for new endpoints.
|
||||||
|
- New routes go in `backend/routes/` as separate files, mounted in `server.js`.
|
||||||
|
- Use async/await with try-catch. Wrap db calls in `db.get()`, `db.all()`, `db.run()`.
|
||||||
|
- Keep responses consistent: `{ success: true, data: ... }` or `{ error: "message" }`.
|
||||||
|
- Add JSDoc-style comments only for non-obvious logic.
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
- Never modify tables directly in route code. Create migration scripts in `backend/` (pattern: `migrate_<feature>.js`).
|
||||||
|
- Always back up the DB before migrations.
|
||||||
|
- Add appropriate indexes for new query patterns.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- After making changes, verify the server starts cleanly: `node backend/server.js`.
|
||||||
|
- Test new endpoints with curl examples.
|
||||||
|
- Check that existing endpoints still work (no regressions).
|
||||||
105
.claude/agents/frontend.md
Normal file
105
.claude/agents/frontend.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Frontend Agent — CVE Dashboard
|
||||||
|
|
||||||
|
## Role
|
||||||
|
You are the frontend specialist for the CVE Dashboard project. You build and maintain the React UI, handle client-side state, manage API communication, and implement user-facing features.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Framework:** React 18.2.4 (Create React App)
|
||||||
|
- **Styling:** Tailwind CSS (loaded via CDN in `public/index.html`)
|
||||||
|
- **Icons:** Lucide React
|
||||||
|
- **State:** React useState/useEffect + Context API (AuthContext)
|
||||||
|
- **API Communication:** Fetch API with credentials: 'include' for session cookies
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/App.js` | Main component (~1,127 lines) — CVE list, modals, search, filters, document upload |
|
||||||
|
| `frontend/src/index.js` | React entry point |
|
||||||
|
| `frontend/src/App.css` | Global styles |
|
||||||
|
| `frontend/src/components/LoginForm.js` | Login page |
|
||||||
|
| `frontend/src/components/UserMenu.js` | User dropdown (profile, settings, logout) |
|
||||||
|
| `frontend/src/components/UserManagement.js` | Admin user management interface |
|
||||||
|
| `frontend/src/components/AuditLog.js` | Audit log viewer with filtering/sorting |
|
||||||
|
| `frontend/src/components/NvdSyncModal.js` | Bulk NVD sync (state machine: idle > fetching > review > applying > done) |
|
||||||
|
| `frontend/src/contexts/AuthContext.js` | Auth state + `useAuth()` hook |
|
||||||
|
| `frontend/public/index.html` | HTML shell (includes Tailwind CDN script) |
|
||||||
|
| `frontend/.env.example` | Environment variable template |
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```
|
||||||
|
REACT_APP_API_BASE=http://<server-ip>:3001/api
|
||||||
|
REACT_APP_API_HOST=http://<server-ip>:3001
|
||||||
|
```
|
||||||
|
**Critical:** React caches env vars at build time. After `.env` changes, the dev server must be fully restarted (not just refreshed).
|
||||||
|
|
||||||
|
### API Base URL
|
||||||
|
All fetch calls use `process.env.REACT_APP_API_BASE` as the base URL. Requests include `credentials: 'include'` for session cookie auth.
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
1. `LoginForm.js` posts credentials to `/api/auth/login`
|
||||||
|
2. Server returns session cookie (httpOnly, sameSite: lax)
|
||||||
|
3. `AuthContext.js` checks `/api/auth/me` on mount to restore sessions
|
||||||
|
4. `useAuth()` hook provides `user`, `login()`, `logout()`, `loading` throughout the app
|
||||||
|
5. Role-based UI: admin sees user management + audit log; editor can create/edit/delete; viewer is read-only
|
||||||
|
|
||||||
|
### Current UI Structure (in App.js)
|
||||||
|
- **Header**: App title, stats bar, Quick Check input, "Add CVE" button, "Sync with NVD" button (editor/admin), User Menu
|
||||||
|
- **Filters**: Search input, vendor dropdown, severity dropdown
|
||||||
|
- **CVE List**: Grouped by CVE ID, each group shows vendor rows with status badges, document counts, edit/delete buttons
|
||||||
|
- **Modals**: Add CVE (with NVD auto-fill), Edit CVE (with NVD update), Document Upload, NVD Sync
|
||||||
|
- **Admin Views**: User Management tab, Audit Log tab
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
- New UI features should be extracted into separate components under `frontend/src/components/`.
|
||||||
|
- Use functional components with hooks. No class components.
|
||||||
|
- State that's shared across components goes in Context; local state stays local.
|
||||||
|
- Destructure props. Use meaningful variable names.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Use Tailwind CSS utility classes exclusively. No custom CSS unless absolutely necessary.
|
||||||
|
- Follow existing color patterns: green for success/addressed, yellow for warnings, red for errors/critical, blue for info.
|
||||||
|
- Responsive design: use Tailwind responsive prefixes (sm:, md:, lg:).
|
||||||
|
- Dark mode is not currently implemented — do not add it unless requested.
|
||||||
|
|
||||||
|
### API Communication
|
||||||
|
- Always use `fetch()` with `credentials: 'include'`.
|
||||||
|
- Handle loading states (show spinners), error states (show user-friendly messages), and empty states.
|
||||||
|
- On 401 responses, redirect to login (session expired).
|
||||||
|
- Pattern:
|
||||||
|
```js
|
||||||
|
const res = await fetch(`${process.env.REACT_APP_API_BASE}/endpoint`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) { /* handle error */ }
|
||||||
|
const result = await res.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role-Based UI
|
||||||
|
- Check `user.role` before rendering admin/editor controls.
|
||||||
|
- Viewers see data but no create/edit/delete buttons.
|
||||||
|
- Editors see create/edit/delete for CVEs and documents.
|
||||||
|
- Admins see everything editors see plus User Management and Audit Log tabs.
|
||||||
|
|
||||||
|
### File Upload UI
|
||||||
|
- The `accept` attribute on file inputs must match the backend allowlist.
|
||||||
|
- Current allowed: `.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.svg,.zip,.tar,.gz,.7z,.rar,.eml,.msg`
|
||||||
|
- Max file size: 10MB (enforced backend, show friendly message on 413).
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- No inline styles — use Tailwind classes.
|
||||||
|
- Extract repeated logic into custom hooks or utility functions.
|
||||||
|
- Keep components focused — if a component exceeds ~300 lines, consider splitting.
|
||||||
|
- Use `key` props correctly on lists (use unique IDs, not array indexes).
|
||||||
|
- Clean up useEffect subscriptions and timers.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- After making changes, verify the frontend compiles: `cd frontend && npm start` (or check for build errors).
|
||||||
|
- Test in browser: check console for errors, verify API calls succeed.
|
||||||
|
- Test role-based visibility with different user accounts.
|
||||||
138
.claude/agents/security.md
Normal file
138
.claude/agents/security.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Security Agent — CVE Dashboard
|
||||||
|
|
||||||
|
## Role
|
||||||
|
You are the security specialist for the CVE Dashboard project. You perform code reviews, dependency audits, and vulnerability assessments. You identify security issues and recommend fixes aligned with the project's existing security framework.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
### Application Profile
|
||||||
|
- **Type:** Internal vulnerability management tool (Charter Communications)
|
||||||
|
- **Users:** Security team members with assigned roles (admin/editor/viewer)
|
||||||
|
- **Data Sensitivity:** CVE remediation status, vendor documentation, user credentials
|
||||||
|
- **Exposure:** Internal network (home lab / corporate network), not internet-facing
|
||||||
|
|
||||||
|
### Tech Stack Security Surface
|
||||||
|
| Layer | Technology | Key Risks |
|
||||||
|
|-------|-----------|-----------|
|
||||||
|
| Frontend | React 18, Tailwind CDN | XSS, CSRF, sensitive data in client state |
|
||||||
|
| Backend | Express.js 4.x | Injection, auth bypass, path traversal, DoS |
|
||||||
|
| Database | SQLite3 | SQL injection, file access, no encryption at rest |
|
||||||
|
| Auth | bcryptjs + session cookies | Session fixation, brute force, weak passwords |
|
||||||
|
| File Upload | Multer | Unrestricted upload, path traversal, malicious files |
|
||||||
|
| External API | NVD API 2.0 | SSRF, response injection, rate limit abuse |
|
||||||
|
|
||||||
|
### Existing Security Controls
|
||||||
|
These are already implemented — verify they remain intact during reviews:
|
||||||
|
|
||||||
|
**Input Validation (backend/server.js)**
|
||||||
|
- CVE ID: `/^CVE-\d{4}-\d{4,}$/` via `isValidCveId()`
|
||||||
|
- Vendor: non-empty, max 200 chars via `isValidVendor()`
|
||||||
|
- Severity: enum `VALID_SEVERITIES` (Critical, High, Medium, Low)
|
||||||
|
- Status: enum `VALID_STATUSES` (Open, Addressed, In Progress, Resolved)
|
||||||
|
- Document type: enum `VALID_DOC_TYPES` (advisory, email, screenshot, patch, other)
|
||||||
|
- Description: max 10,000 chars
|
||||||
|
- Published date: `YYYY-MM-DD` format
|
||||||
|
|
||||||
|
**File Upload Security**
|
||||||
|
- Extension allowlist: `ALLOWED_EXTENSIONS` — documents only, all executables blocked
|
||||||
|
- MIME type validation: `ALLOWED_MIME_PREFIXES` — image/*, text/*, application/pdf, Office types
|
||||||
|
- Filename sanitization: strips `/`, `\`, `..`, null bytes
|
||||||
|
- File size limit: 10MB
|
||||||
|
|
||||||
|
**Path Traversal Prevention**
|
||||||
|
- `sanitizePathSegment(segment)` — strips dangerous characters from path components
|
||||||
|
- `isPathWithinUploads(targetPath)` — verifies resolved path stays within uploads root
|
||||||
|
|
||||||
|
**Authentication & Sessions**
|
||||||
|
- bcryptjs password hashing (default rounds)
|
||||||
|
- Session cookies: `httpOnly: true`, `sameSite: 'lax'`, `secure` in production
|
||||||
|
- 24-hour session expiry
|
||||||
|
- Role-based access control on all state-changing endpoints
|
||||||
|
|
||||||
|
**Security Headers**
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options: DENY`
|
||||||
|
- `X-XSS-Protection: 1; mode=block`
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
|
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||||
|
|
||||||
|
**Error Handling**
|
||||||
|
- Generic 500 responses (no `err.message` to client)
|
||||||
|
- Full errors logged server-side
|
||||||
|
- Static file serving: `dotfiles: 'deny'`, `index: false`
|
||||||
|
- JSON body limit: 1MB
|
||||||
|
|
||||||
|
### Key Files to Review
|
||||||
|
| File | Security Relevance |
|
||||||
|
|------|-------------------|
|
||||||
|
| `backend/server.js` | Central security framework, all core routes, file handling |
|
||||||
|
| `backend/middleware/auth.js` | Authentication and authorization middleware |
|
||||||
|
| `backend/routes/auth.js` | Login/logout, session management |
|
||||||
|
| `backend/routes/users.js` | User CRUD, password handling |
|
||||||
|
| `backend/routes/nvdLookup.js` | External API proxy (SSRF risk) |
|
||||||
|
| `backend/routes/auditLog.js` | Audit log access control |
|
||||||
|
| `frontend/src/contexts/AuthContext.js` | Client-side auth state |
|
||||||
|
| `frontend/src/App.js` | Client-side input handling, API calls |
|
||||||
|
| `frontend/src/components/LoginForm.js` | Credential handling |
|
||||||
|
| `.gitignore` | Verify secrets are excluded |
|
||||||
|
|
||||||
|
## Review Checklists
|
||||||
|
|
||||||
|
### Code Review (run on all PRs/changes)
|
||||||
|
1. **Injection** — Are all database queries parameterized? No string interpolation in SQL.
|
||||||
|
2. **Authentication** — Do new state-changing endpoints use `requireAuth(db)` + `requireRole()`?
|
||||||
|
3. **Authorization** — Is role checking correct? (admin-only vs editor+ vs all authenticated)
|
||||||
|
4. **Input Validation** — Are all user inputs validated before use? New fields need validators.
|
||||||
|
5. **File Operations** — Do file/directory operations use `sanitizePathSegment()` + `isPathWithinUploads()`?
|
||||||
|
6. **Error Handling** — Do 500 responses avoid leaking `err.message`? Are errors logged server-side?
|
||||||
|
7. **Audit Logging** — Are create/update/delete actions logged via `logAudit()`?
|
||||||
|
8. **CORS** — Is `CORS_ORIGINS` still restrictive? No wildcards in production.
|
||||||
|
9. **Dependencies** — Any new packages? Check for known vulnerabilities.
|
||||||
|
10. **Secrets** — No hardcoded credentials, API keys, or secrets in code. All in `.env`.
|
||||||
|
|
||||||
|
### Dependency Audit
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && npm audit
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm audit
|
||||||
|
```
|
||||||
|
- Flag any `high` or `critical` severity findings.
|
||||||
|
- Check for outdated packages with known CVEs: `npm outdated`.
|
||||||
|
- Review new dependencies: check npm page, weekly downloads, last publish date, maintainer reputation.
|
||||||
|
|
||||||
|
### OWASP Top 10 Mapping
|
||||||
|
| OWASP Category | Status | Notes |
|
||||||
|
|---------------|--------|-------|
|
||||||
|
| A01 Broken Access Control | Mitigated | RBAC + session auth on all endpoints |
|
||||||
|
| A02 Cryptographic Failures | Partial | bcrypt for passwords; no encryption at rest for DB/files |
|
||||||
|
| A03 Injection | Mitigated | Parameterized queries, input validation |
|
||||||
|
| A04 Insecure Design | Acceptable | Internal tool with limited user base |
|
||||||
|
| A05 Security Misconfiguration | Mitigated | Security headers, CORS config, dotfiles denied |
|
||||||
|
| A06 Vulnerable Components | Monitor | Run `npm audit` regularly |
|
||||||
|
| A07 Auth Failures | Mitigated | Session-based auth, bcrypt, httpOnly cookies |
|
||||||
|
| A08 Data Integrity Failures | Partial | File type validation; no code signing |
|
||||||
|
| A09 Logging & Monitoring | Mitigated | Audit logging on all mutations |
|
||||||
|
| A10 SSRF | Partial | NVD proxy validates CVE ID format; review for Ivanti integration |
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
When reporting findings, use this structure:
|
||||||
|
```
|
||||||
|
### [SEVERITY] Finding Title
|
||||||
|
- **Location:** file:line_number
|
||||||
|
- **Issue:** Description of the vulnerability
|
||||||
|
- **Impact:** What an attacker could achieve
|
||||||
|
- **Recommendation:** Specific fix with code example
|
||||||
|
- **OWASP:** Category reference
|
||||||
|
```
|
||||||
|
|
||||||
|
Severity levels: CRITICAL, HIGH, MEDIUM, LOW, INFO
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
1. Never suggest disabling security controls for convenience.
|
||||||
|
2. Recommendations must be compatible with the existing security framework — extend it, don't replace it.
|
||||||
|
3. Flag any regression in existing security controls immediately.
|
||||||
|
4. For dependency issues, provide the specific CVE and affected version range.
|
||||||
|
5. Consider the threat model — this is an internal tool, not internet-facing. Prioritize accordingly.
|
||||||
|
6. When reviewing file upload changes, always verify both frontend `accept` attribute and backend allowlist stay in sync.
|
||||||
|
7. Do not recommend changes that would break existing functionality without a migration path.
|
||||||
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
|
PORT=3001
|
||||||
API_HOST=localhost
|
API_HOST=localhost
|
||||||
CORS_ORIGINS=http://localhost:3000
|
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=
|
||||||
|
|||||||
@@ -219,8 +219,13 @@ function createAuthRouter(db, logAudit) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up expired sessions (can be called periodically)
|
// Clean up expired sessions (admin only)
|
||||||
router.post('/cleanup-sessions', async (req, res) => {
|
router.post('/cleanup-sessions', async (req, res) => {
|
||||||
|
// Basic auth check - require a valid session to call this
|
||||||
|
const sessionId = req.cookies?.session_id;
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
|
|||||||
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 createUsersRouter = require('./routes/users');
|
||||||
const createAuditLogRouter = require('./routes/auditLog');
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
const logAudit = require('./helpers/auditLog');
|
const logAudit = require('./helpers/auditLog');
|
||||||
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -26,20 +27,90 @@ const CORS_ORIGINS = process.env.CORS_ORIGINS
|
|||||||
? process.env.CORS_ORIGINS.split(',')
|
? process.env.CORS_ORIGINS.split(',')
|
||||||
: ['http://localhost:3000'];
|
: ['http://localhost:3000'];
|
||||||
|
|
||||||
|
// ========== SECURITY HELPERS ==========
|
||||||
|
|
||||||
|
// Allowed file extensions for document uploads (documents only, no executables)
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif',
|
||||||
|
'.txt', '.csv', '.log', '.msg', '.eml',
|
||||||
|
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.odt', '.ods', '.odp',
|
||||||
|
'.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml',
|
||||||
|
'.zip', '.gz', '.tar', '.7z'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Allowed MIME type prefixes
|
||||||
|
const ALLOWED_MIME_PREFIXES = [
|
||||||
|
'image/', 'text/', 'application/pdf',
|
||||||
|
'application/msword', 'application/vnd.openxmlformats',
|
||||||
|
'application/vnd.ms-', 'application/vnd.oasis.opendocument',
|
||||||
|
'application/rtf', 'application/json', 'application/xml',
|
||||||
|
'application/vnd.ms-outlook', 'message/rfc822',
|
||||||
|
'application/zip', 'application/gzip', 'application/x-7z',
|
||||||
|
'application/x-tar', 'application/octet-stream'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sanitize a single path segment (cveId, vendor, filename) to prevent traversal
|
||||||
|
function sanitizePathSegment(segment) {
|
||||||
|
if (!segment || typeof segment !== 'string') return '';
|
||||||
|
// Remove path separators, null bytes, and .. sequences
|
||||||
|
return segment
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/\.\./g, '')
|
||||||
|
.replace(/[\/\\]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that a resolved path is within the uploads directory
|
||||||
|
function isPathWithinUploads(targetPath) {
|
||||||
|
const uploadsRoot = path.resolve('uploads');
|
||||||
|
const resolved = path.resolve(targetPath);
|
||||||
|
return resolved.startsWith(uploadsRoot + path.sep) || resolved === uploadsRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CVE ID format
|
||||||
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
function isValidCveId(cveId) {
|
||||||
|
return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed enum values
|
||||||
|
const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low'];
|
||||||
|
const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved'];
|
||||||
|
const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other'];
|
||||||
|
|
||||||
|
// Validate vendor name - printable chars, reasonable length
|
||||||
|
function isValidVendor(vendor) {
|
||||||
|
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
||||||
|
}
|
||||||
|
|
||||||
// Log all incoming requests
|
// Log all incoming requests
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||||
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: CORS_ORIGINS,
|
origin: CORS_ORIGINS,
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use('/uploads', express.static('uploads'));
|
app.use('/uploads', express.static('uploads', {
|
||||||
|
dotfiles: 'deny',
|
||||||
|
index: false
|
||||||
|
}));
|
||||||
|
|
||||||
// Database connection
|
// Database connection
|
||||||
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
||||||
@@ -56,6 +127,9 @@ app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit))
|
|||||||
// Audit log routes (admin only)
|
// Audit log routes (admin only)
|
||||||
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
|
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
|
// Simple storage - upload to temp directory first
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
@@ -67,12 +141,28 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
cb(null, `${timestamp}-${file.originalname}`);
|
// Sanitize original filename - strip path components and dangerous chars
|
||||||
|
const safeName = sanitizePathSegment(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
cb(null, `${timestamp}-${safeName}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// File filter - reject executables and non-allowed types
|
||||||
|
function fileFilter(req, file, cb) {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||||
|
return cb(new Error(`File type '${ext}' is not allowed. Allowed types: ${[...ALLOWED_EXTENSIONS].join(', ')}`));
|
||||||
|
}
|
||||||
|
const mimeAllowed = ALLOWED_MIME_PREFIXES.some(prefix => file.mimetype.startsWith(prefix));
|
||||||
|
if (!mimeAllowed) {
|
||||||
|
return cb(new Error(`MIME type '${file.mimetype}' is not allowed.`));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
|
fileFilter: fileFilter,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,15 +173,9 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
|||||||
const { search, vendor, severity, status } = req.query;
|
const { search, vendor, severity, status } = req.query;
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT c.*,
|
SELECT c.*, COUNT(d.id) as document_count
|
||||||
COUNT(d.id) as document_count,
|
|
||||||
CASE
|
|
||||||
WHEN COUNT(CASE WHEN d.type = 'advisory' THEN 1 END) > 0
|
|
||||||
THEN 'Complete'
|
|
||||||
ELSE 'Incomplete'
|
|
||||||
END as doc_status
|
|
||||||
FROM cves c
|
FROM cves c
|
||||||
LEFT JOIN documents d ON c.cve_id = d.cve_id
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -119,12 +203,20 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
|||||||
db.all(query, params, (err, rows) => {
|
db.all(query, params, (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Error fetching CVEs:', err);
|
console.error('Error fetching CVEs:', err);
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
res.json(rows.map(r => r.cve_id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
||||||
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
@@ -132,7 +224,6 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|||||||
const query = `
|
const query = `
|
||||||
SELECT c.*,
|
SELECT c.*,
|
||||||
COUNT(d.id) as total_documents,
|
COUNT(d.id) as total_documents,
|
||||||
COUNT(CASE WHEN d.type = 'advisory' THEN 1 END) as has_advisory,
|
|
||||||
COUNT(CASE WHEN d.type = 'email' THEN 1 END) as has_email,
|
COUNT(CASE WHEN d.type = 'email' THEN 1 END) as has_email,
|
||||||
COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot
|
COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot
|
||||||
FROM cves c
|
FROM cves c
|
||||||
@@ -143,7 +234,7 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, [cveId], (err, rows) => {
|
db.all(query, [cveId], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -160,14 +251,12 @@ app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|||||||
severity: row.severity,
|
severity: row.severity,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
total_documents: row.total_documents,
|
total_documents: row.total_documents,
|
||||||
compliance: {
|
doc_types: {
|
||||||
advisory: row.has_advisory > 0,
|
|
||||||
email: row.has_email > 0,
|
email: row.has_email > 0,
|
||||||
screenshot: row.has_screenshot > 0
|
screenshot: row.has_screenshot > 0
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
addressed: true,
|
addressed: true
|
||||||
has_required_docs: rows.some(row => row.has_advisory > 0)
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -185,7 +274,7 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, [cveId], (err, rows) => {
|
db.all(query, [cveId], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
@@ -194,31 +283,39 @@ app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
||||||
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
console.log('=== ADD CVE REQUEST ===');
|
|
||||||
console.log('Body:', req.body);
|
|
||||||
console.log('=======================');
|
|
||||||
|
|
||||||
const { cve_id, vendor, severity, description, published_date } = req.body;
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
if (!cve_id || !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||||
|
}
|
||||||
|
if (!vendor || !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Vendor is required and must be under 200 characters.' });
|
||||||
|
}
|
||||||
|
if (!severity || !VALID_SEVERITIES.includes(severity)) {
|
||||||
|
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (!description || typeof description !== 'string' || description.length > 10000) {
|
||||||
|
return res.status(400).json({ error: 'Description is required and must be under 10000 characters.' });
|
||||||
|
}
|
||||||
|
if (!published_date || !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
||||||
|
return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' });
|
||||||
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log('Query:', query);
|
|
||||||
console.log('Values:', [cve_id, vendor, severity, description, published_date]);
|
|
||||||
|
|
||||||
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
db.run(query, [cve_id, vendor, severity, description, published_date], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('DATABASE ERROR:', err); // Make sure this is here
|
console.error('DATABASE ERROR:', err);
|
||||||
// ... rest of error handling
|
|
||||||
// Check if it's a duplicate CVE_ID + Vendor combination
|
|
||||||
if (err.message.includes('UNIQUE constraint failed')) {
|
if (err.message.includes('UNIQUE constraint failed')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: 'Failed to create CVE entry.' });
|
||||||
}
|
}
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -243,11 +340,15 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
|||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!status || !VALID_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
||||||
|
|
||||||
db.run(query, [status, cveId], function(err) {
|
db.run(query, [status, cveId], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -262,6 +363,317 @@ 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) {
|
||||||
|
console.error('NVD sync update error:', err);
|
||||||
|
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
||||||
|
|
||||||
|
// Edit single CVE entry (editor or admin)
|
||||||
|
app.put('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
||||||
|
|
||||||
|
// Input validation for provided fields
|
||||||
|
if (cve_id !== undefined && !isValidCveId(cve_id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||||
|
}
|
||||||
|
if (vendor !== undefined && !isValidVendor(vendor)) {
|
||||||
|
return res.status(400).json({ error: 'Vendor must be under 200 characters.' });
|
||||||
|
}
|
||||||
|
if (severity !== undefined && !VALID_SEVERITIES.includes(severity)) {
|
||||||
|
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
||||||
|
}
|
||||||
|
if (description !== undefined && (typeof description !== 'string' || description.length > 10000)) {
|
||||||
|
return res.status(400).json({ error: 'Description must be under 10000 characters.' });
|
||||||
|
}
|
||||||
|
if (published_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
||||||
|
return res.status(400).json({ error: 'Published date must be in YYYY-MM-DD format.' });
|
||||||
|
}
|
||||||
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
||||||
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing row first
|
||||||
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (!existing) return res.status(404).json({ error: 'CVE entry not found' });
|
||||||
|
|
||||||
|
const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status };
|
||||||
|
|
||||||
|
const newCveId = cve_id !== undefined ? cve_id : existing.cve_id;
|
||||||
|
const newVendor = vendor !== undefined ? vendor : existing.vendor;
|
||||||
|
const cveIdChanged = newCveId !== existing.cve_id;
|
||||||
|
const vendorChanged = newVendor !== existing.vendor;
|
||||||
|
|
||||||
|
const doUpdate = () => {
|
||||||
|
// Build dynamic SET clause
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
if (cve_id !== undefined) { fields.push('cve_id = ?'); values.push(cve_id); }
|
||||||
|
if (vendor !== undefined) { fields.push('vendor = ?'); values.push(vendor); }
|
||||||
|
if (severity !== undefined) { fields.push('severity = ?'); values.push(severity); }
|
||||||
|
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
|
||||||
|
if (published_date !== undefined) { fields.push('published_date = ?'); values.push(published_date); }
|
||||||
|
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
|
|
||||||
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
db.run(`UPDATE cves SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
||||||
|
if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const after = {
|
||||||
|
cve_id: newCveId, vendor: newVendor,
|
||||||
|
severity: severity !== undefined ? severity : existing.severity,
|
||||||
|
description: description !== undefined ? description : existing.description,
|
||||||
|
published_date: published_date !== undefined ? published_date : existing.published_date,
|
||||||
|
status: status !== undefined ? status : existing.status
|
||||||
|
};
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_edit',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: newCveId,
|
||||||
|
details: { before, after },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'CVE updated successfully', changes: this.changes });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cveIdChanged || vendorChanged) {
|
||||||
|
// Check UNIQUE constraint
|
||||||
|
db.get('SELECT id FROM cves WHERE cve_id = ? AND vendor = ? AND id != ?', [newCveId, newVendor, id], (checkErr, conflict) => {
|
||||||
|
if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
|
||||||
|
|
||||||
|
// Rename document directory (with path traversal prevention)
|
||||||
|
const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor));
|
||||||
|
const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor));
|
||||||
|
|
||||||
|
if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(oldDir)) {
|
||||||
|
const newParent = path.join('uploads', newCveId);
|
||||||
|
if (!fs.existsSync(newParent)) {
|
||||||
|
fs.mkdirSync(newParent, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.renameSync(oldDir, newDir);
|
||||||
|
|
||||||
|
// Clean up old cve_id directory if empty
|
||||||
|
const oldParent = path.join('uploads', existing.cve_id);
|
||||||
|
if (fs.existsSync(oldParent)) {
|
||||||
|
const remaining = fs.readdirSync(oldParent);
|
||||||
|
if (remaining.length === 0) fs.rmdirSync(oldParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update documents table - file paths
|
||||||
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [existing.cve_id, existing.vendor], (docErr, docs) => {
|
||||||
|
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor);
|
||||||
|
const newPrefix = path.join('uploads', newCveId, newVendor);
|
||||||
|
|
||||||
|
let docUpdated = 0;
|
||||||
|
const totalDocs = docs.length;
|
||||||
|
|
||||||
|
const finishDocUpdate = () => {
|
||||||
|
if (docUpdated >= totalDocs) doUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totalDocs === 0) {
|
||||||
|
doUpdate();
|
||||||
|
} else {
|
||||||
|
docs.forEach((doc) => {
|
||||||
|
const newFilePath = doc.file_path.replace(oldPrefix, newPrefix);
|
||||||
|
db.run('UPDATE documents SET cve_id = ?, vendor = ?, file_path = ? WHERE id = ?',
|
||||||
|
[newCveId, newVendor, newFilePath, doc.id],
|
||||||
|
(docUpdateErr) => {
|
||||||
|
if (docUpdateErr) console.error('Error updating document:', docUpdateErr);
|
||||||
|
docUpdated++;
|
||||||
|
finishDocUpdate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
doUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
||||||
|
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { cveId } = req.params;
|
||||||
|
|
||||||
|
// Get all rows for this CVE ID to know what we're deleting
|
||||||
|
db.all('SELECT * FROM cves WHERE cve_id = ?', [cveId], (err, rows) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
||||||
|
|
||||||
|
// Delete all documents from DB
|
||||||
|
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
||||||
|
if (docErr) console.error('Error deleting documents:', docErr);
|
||||||
|
|
||||||
|
// Delete all CVE rows
|
||||||
|
db.run('DELETE FROM cves WHERE cve_id = ?', [cveId], function(cveErr) {
|
||||||
|
if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
// Remove upload directory (with path traversal prevention)
|
||||||
|
const safeCveId = sanitizePathSegment(cveId);
|
||||||
|
const cveDir = path.join('uploads', safeCveId);
|
||||||
|
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
|
||||||
|
fs.rmSync(cveDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_delete',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: cveId,
|
||||||
|
details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: this.changes });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete single CVE vendor entry (editor or admin)
|
||||||
|
app.delete('/api/cves/:id', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
||||||
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
||||||
|
|
||||||
|
// Delete associated documents from DB
|
||||||
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
||||||
|
if (docErr) console.error('Error fetching documents:', docErr);
|
||||||
|
|
||||||
|
// Delete document files from disk (with path traversal prevention)
|
||||||
|
if (docs && docs.length > 0) {
|
||||||
|
docs.forEach(doc => {
|
||||||
|
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
|
||||||
|
fs.unlinkSync(doc.file_path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete documents from DB
|
||||||
|
db.run('DELETE FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (delDocErr) => {
|
||||||
|
if (delDocErr) console.error('Error deleting documents from DB:', delDocErr);
|
||||||
|
|
||||||
|
// Delete CVE row
|
||||||
|
db.run('DELETE FROM cves WHERE id = ?', [id], function(delErr) {
|
||||||
|
if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
||||||
|
|
||||||
|
// Clean up directories (with path traversal prevention)
|
||||||
|
const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor));
|
||||||
|
if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) {
|
||||||
|
fs.rmSync(safeVendorDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id));
|
||||||
|
if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) {
|
||||||
|
const remaining = fs.readdirSync(safeCveDir);
|
||||||
|
if (remaining.length === 0) fs.rmdirSync(safeCveDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_delete',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: cve.cve_id,
|
||||||
|
details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== DOCUMENT ENDPOINTS ==========
|
// ========== DOCUMENT ENDPOINTS ==========
|
||||||
|
|
||||||
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
||||||
@@ -281,7 +693,7 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, params, (err, rows) => {
|
db.all(query, params, (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
@@ -291,16 +703,17 @@ app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
|||||||
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
|
||||||
upload.single('file')(req, res, (err) => {
|
upload.single('file')(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('MULTER ERROR:', err);
|
console.error('Upload error:', err.message);
|
||||||
return res.status(500).json({ error: 'File upload failed: ' + err.message });
|
// Show file validation errors to the user; hide other internal errors
|
||||||
|
if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(400).json({ error: 'File exceeds the 10MB size limit.' });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'File upload failed.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('=== UPLOAD REQUEST RECEIVED ===');
|
|
||||||
console.log('CVE ID:', req.params.cveId);
|
|
||||||
console.log('Body:', req.body);
|
|
||||||
console.log('File:', req.file);
|
|
||||||
console.log('================================');
|
|
||||||
|
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
const { type, notes, vendor } = req.body;
|
const { type, notes, vendor } = req.body;
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
@@ -311,18 +724,41 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!vendor) {
|
if (!vendor) {
|
||||||
console.error('ERROR: Vendor is required');
|
// Clean up temp file
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
return res.status(400).json({ error: 'Vendor is required' });
|
return res.status(400).json({ error: 'Vendor is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate document type
|
||||||
|
if (type && !VALID_DOC_TYPES.includes(type)) {
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: `Invalid document type. Must be one of: ${VALID_DOC_TYPES.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize path segments to prevent directory traversal
|
||||||
|
const safeCveId = sanitizePathSegment(cveId);
|
||||||
|
const safeVendor = sanitizePathSegment(vendor);
|
||||||
|
const safeFilename = sanitizePathSegment(file.filename);
|
||||||
|
|
||||||
|
if (!safeCveId || !safeVendor || !safeFilename) {
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID, vendor, or filename.' });
|
||||||
|
}
|
||||||
|
|
||||||
// Move file from temp to proper location
|
// Move file from temp to proper location
|
||||||
const finalDir = path.join('uploads', cveId, vendor);
|
const finalDir = path.join('uploads', safeCveId, safeVendor);
|
||||||
|
const finalPath = path.join(finalDir, safeFilename);
|
||||||
|
|
||||||
|
// Verify paths stay within uploads directory
|
||||||
|
if (!isPathWithinUploads(finalDir) || !isPathWithinUploads(finalPath)) {
|
||||||
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: 'Invalid file path.' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(finalDir)) {
|
if (!fs.existsSync(finalDir)) {
|
||||||
fs.mkdirSync(finalDir, { recursive: true });
|
fs.mkdirSync(finalDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalPath = path.join(finalDir, file.filename);
|
|
||||||
|
|
||||||
// Move file from temp to final location
|
// Move file from temp to final location
|
||||||
fs.renameSync(file.path, finalPath);
|
fs.renameSync(file.path, finalPath);
|
||||||
|
|
||||||
@@ -344,12 +780,12 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
notes
|
notes
|
||||||
], function(err) {
|
], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('DATABASE ERROR:', err);
|
console.error('Document insert error:', err);
|
||||||
// If database insert fails, delete the file
|
// If database insert fails, delete the file
|
||||||
if (fs.existsSync(finalPath)) {
|
if (fs.existsSync(finalPath)) {
|
||||||
fs.unlinkSync(finalPath);
|
fs.unlinkSync(finalPath);
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -365,7 +801,6 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
message: 'Document uploaded successfully',
|
message: 'Document uploaded successfully',
|
||||||
file: {
|
file: {
|
||||||
name: file.originalname,
|
name: file.originalname,
|
||||||
path: finalPath,
|
|
||||||
size: fileSizeKB
|
size: fileSizeKB
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -379,16 +814,17 @@ app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, re
|
|||||||
// First get the file path to delete the actual file
|
// First get the file path to delete the actual file
|
||||||
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row && fs.existsSync(row.file_path)) {
|
// Only delete file if path is within uploads directory
|
||||||
|
if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) {
|
||||||
fs.unlinkSync(row.file_path);
|
fs.unlinkSync(row.file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
logAudit(db, {
|
logAudit(db, {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
@@ -412,7 +848,7 @@ app.get('/api/vendors', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.all(query, [], (err, rows) => {
|
db.all(query, [], (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(rows.map(r => r.vendor));
|
res.json(rows.map(r => r.vendor));
|
||||||
});
|
});
|
||||||
@@ -434,7 +870,7 @@ app.get('/api/stats', requireAuth(db), (req, res) => {
|
|||||||
|
|
||||||
db.get(query, [], (err, row) => {
|
db.get(query, [], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
||||||
}
|
}
|
||||||
res.json(row);
|
res.json(row);
|
||||||
});
|
});
|
||||||
|
|||||||
0
frontend/cve_database.db
Normal file
0
frontend/cve_database.db
Normal file
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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, Edit2, ChevronDown } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
import AuditLog from './components/AuditLog';
|
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_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
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 [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
|
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||||
const [newCVE, setNewCVE] = useState({
|
const [newCVE, setNewCVE] = useState({
|
||||||
cve_id: '',
|
cve_id: '',
|
||||||
vendor: '',
|
vendor: '',
|
||||||
@@ -37,6 +39,55 @@ export default function App() {
|
|||||||
published_date: new Date().toISOString().split('T')[0]
|
published_date: new Date().toISOString().split('T')[0]
|
||||||
});
|
});
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
|
const [nvdLoading, setNvdLoading] = useState(false);
|
||||||
|
const [nvdError, setNvdError] = useState(null);
|
||||||
|
const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
|
||||||
|
const [showEditCVE, setShowEditCVE] = useState(false);
|
||||||
|
const [editingCVE, setEditingCVE] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
cve_id: '', vendor: '', severity: 'Medium', description: '', published_date: '', status: 'Open'
|
||||||
|
});
|
||||||
|
const [editNvdLoading, setEditNvdLoading] = useState(false);
|
||||||
|
const [editNvdError, setEditNvdError] = useState(null);
|
||||||
|
const [editNvdAutoFilled, setEditNvdAutoFilled] = useState(false);
|
||||||
|
const [expandedCVEs, setExpandedCVEs] = useState({});
|
||||||
|
|
||||||
|
const toggleCVEExpand = (cveId) => {
|
||||||
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const fetchCVEs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -163,6 +214,9 @@ export default function App() {
|
|||||||
description: '',
|
description: '',
|
||||||
published_date: new Date().toISOString().split('T')[0]
|
published_date: new Date().toISOString().split('T')[0]
|
||||||
});
|
});
|
||||||
|
setNvdLoading(false);
|
||||||
|
setNvdError(null);
|
||||||
|
setNvdAutoFilled(false);
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Error: ${err.message}`);
|
alert(`Error: ${err.message}`);
|
||||||
@@ -172,7 +226,7 @@ export default function App() {
|
|||||||
const handleFileUpload = async (cveId, vendor) => {
|
const handleFileUpload = async (cveId, vendor) => {
|
||||||
const fileInput = document.createElement('input');
|
const fileInput = document.createElement('input');
|
||||||
fileInput.type = 'file';
|
fileInput.type = 'file';
|
||||||
fileInput.accept = '.pdf,.png,.jpg,.jpeg,.txt,.doc,.docx';
|
fileInput.accept = '.pdf,.png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.txt,.csv,.log,.msg,.eml,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.html,.htm,.xml,.json,.yaml,.yml,.zip,.gz,.tar,.7z';
|
||||||
|
|
||||||
fileInput.onchange = async (e) => {
|
fileInput.onchange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
@@ -242,6 +296,143 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditCVE = (cve) => {
|
||||||
|
setEditingCVE(cve);
|
||||||
|
setEditForm({
|
||||||
|
cve_id: cve.cve_id,
|
||||||
|
vendor: cve.vendor,
|
||||||
|
severity: cve.severity,
|
||||||
|
description: cve.description || '',
|
||||||
|
published_date: cve.published_date || '',
|
||||||
|
status: cve.status || 'Open'
|
||||||
|
});
|
||||||
|
setEditNvdLoading(false);
|
||||||
|
setEditNvdError(null);
|
||||||
|
setEditNvdAutoFilled(false);
|
||||||
|
setShowEditCVE(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCVESubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingCVE) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
if (editForm.cve_id !== editingCVE.cve_id) body.cve_id = editForm.cve_id;
|
||||||
|
if (editForm.vendor !== editingCVE.vendor) body.vendor = editForm.vendor;
|
||||||
|
if (editForm.severity !== editingCVE.severity) body.severity = editForm.severity;
|
||||||
|
if (editForm.description !== (editingCVE.description || '')) body.description = editForm.description;
|
||||||
|
if (editForm.published_date !== (editingCVE.published_date || '')) body.published_date = editForm.published_date;
|
||||||
|
if (editForm.status !== (editingCVE.status || 'Open')) body.status = editForm.status;
|
||||||
|
|
||||||
|
if (Object.keys(body).length === 0) {
|
||||||
|
alert('No changes detected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/cves/${editingCVE.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to update CVE');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('CVE updated successfully!');
|
||||||
|
setShowEditCVE(false);
|
||||||
|
setEditingCVE(null);
|
||||||
|
fetchCVEs();
|
||||||
|
fetchVendors();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookupNVDForEdit = async (cveId) => {
|
||||||
|
const trimmed = cveId.trim();
|
||||||
|
if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
|
||||||
|
|
||||||
|
setEditNvdLoading(true);
|
||||||
|
setEditNvdError(null);
|
||||||
|
setEditNvdAutoFilled(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();
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
description: data.description || prev.description,
|
||||||
|
severity: data.severity || prev.severity,
|
||||||
|
published_date: data.published_date || prev.published_date
|
||||||
|
}));
|
||||||
|
setEditNvdAutoFilled(true);
|
||||||
|
} catch (err) {
|
||||||
|
setEditNvdError(err.message);
|
||||||
|
} finally {
|
||||||
|
setEditNvdLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCVEEntry = async (cve) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete the "${cve.vendor}" entry for ${cve.cve_id}? This will also delete all associated documents.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves/${cve.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to delete CVE entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Deleted ${cve.vendor} entry for ${cve.cve_id}`);
|
||||||
|
fetchCVEs();
|
||||||
|
fetchVendors();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEntireCVE = async (cveId, vendorCount) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete ALL ${vendorCount} vendor entries for ${cveId}? This will permanently remove all associated documents and files.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves/by-cve-id/${encodeURIComponent(cveId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Failed to delete CVE');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Deleted all entries for ${cveId}`);
|
||||||
|
fetchCVEs();
|
||||||
|
fetchVendors();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch CVEs from API when authenticated
|
// Fetch CVEs from API when authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -297,6 +488,15 @@ export default function App() {
|
|||||||
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
|
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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() && (
|
{canWrite() && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCVE(true)}
|
onClick={() => setShowAddCVE(true)}
|
||||||
@@ -320,6 +520,11 @@ export default function App() {
|
|||||||
<AuditLog onClose={() => setShowAuditLog(false)} />
|
<AuditLog onClose={() => setShowAuditLog(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* NVD Sync Modal */}
|
||||||
|
{showNvdSync && (
|
||||||
|
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add CVE Modal */}
|
{/* Add CVE Modal */}
|
||||||
{showAddCVE && (
|
{showAddCVE && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
@@ -328,7 +533,7 @@ export default function App() {
|
|||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Add CVE Entry</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Add CVE Entry</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCVE(false)}
|
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<XCircle className="w-6 h-6" />
|
<XCircle className="w-6 h-6" />
|
||||||
@@ -347,15 +552,33 @@ export default function App() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
CVE ID *
|
CVE ID *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<input
|
||||||
required
|
type="text"
|
||||||
placeholder="CVE-2024-1234"
|
required
|
||||||
value={newCVE.cve_id}
|
placeholder="CVE-2024-1234"
|
||||||
onChange={(e) => setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()})}
|
value={newCVE.cve_id}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
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>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -425,7 +648,148 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit CVE Modal */}
|
||||||
|
{showEditCVE && editingCVE && (
|
||||||
|
<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-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Edit CVE Entry</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowEditCVE(false); setEditingCVE(null); }}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<strong>Note:</strong> Changing CVE ID or Vendor will move associated documents to the new path.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleEditCVESubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">CVE ID *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={editForm.cve_id}
|
||||||
|
onChange={(e) => { setEditForm({...editForm, cve_id: e.target.value.toUpperCase()}); setEditNvdAutoFilled(false); setEditNvdError(null); }}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{editNvdLoading && (
|
||||||
|
<Loader className="absolute right-3 top-2.5 w-5 h-5 text-[#0476D9] animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editNvdAutoFilled && (
|
||||||
|
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Updated from NVD
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{editNvdError && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{editNvdError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={editForm.vendor}
|
||||||
|
onChange={(e) => setEditForm({...editForm, vendor: 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Severity *</label>
|
||||||
|
<select
|
||||||
|
value={editForm.severity}
|
||||||
|
onChange={(e) => setEditForm({...editForm, severity: 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"
|
||||||
|
>
|
||||||
|
<option value="Critical">Critical</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={editForm.description}
|
||||||
|
onChange={(e) => setEditForm({...editForm, description: e.target.value})}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Published Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={editForm.published_date}
|
||||||
|
onChange={(e) => setEditForm({...editForm, published_date: 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status *</label>
|
||||||
|
<select
|
||||||
|
value={editForm.status}
|
||||||
|
onChange={(e) => setEditForm({...editForm, status: 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"
|
||||||
|
>
|
||||||
|
<option value="Open">Open</option>
|
||||||
|
<option value="Addressed">Addressed</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Resolved">Resolved</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => lookupNVDForEdit(editForm.cve_id)}
|
||||||
|
disabled={editNvdLoading}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium flex items-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${editNvdLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Update from NVD
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowEditCVE(false); setEditingCVE(null); }}
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -481,17 +845,6 @@ export default function App() {
|
|||||||
<p><strong>Status:</strong> {vendorInfo.status}</p>
|
<p><strong>Status:</strong> {vendorInfo.status}</p>
|
||||||
<p><strong>Documents:</strong> {vendorInfo.total_documents} attached</p>
|
<p><strong>Documents:</strong> {vendorInfo.total_documents} attached</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${vendorInfo.compliance.advisory ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
|
||||||
{vendorInfo.compliance.advisory ? '✓' : '✗'} Advisory
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${vendorInfo.compliance.email ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
|
||||||
{vendorInfo.compliance.email ? '✓' : '○'} Email
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${vendorInfo.compliance.screenshot ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
|
||||||
{vendorInfo.compliance.screenshot ? '✓' : '○'} Screenshot
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -598,131 +951,209 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => (
|
{Object.entries(filteredGroupedCVEs).map(([cveId, vendorEntries]) => {
|
||||||
|
const isCVEExpanded = expandedCVEs[cveId];
|
||||||
|
const severityOrder = { 'Critical': 0, 'High': 1, 'Medium': 2, 'Low': 3 };
|
||||||
|
const highestSeverity = vendorEntries.reduce((highest, entry) => {
|
||||||
|
const currentOrder = severityOrder[entry.severity] ?? 4;
|
||||||
|
const highestOrder = severityOrder[highest] ?? 4;
|
||||||
|
return currentOrder < highestOrder ? entry.severity : highest;
|
||||||
|
}, vendorEntries[0].severity);
|
||||||
|
const totalDocCount = vendorEntries.reduce((sum, entry) => sum + (entry.document_count || 0), 0);
|
||||||
|
const overallStatuses = [...new Set(vendorEntries.map(e => e.status))];
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={cveId} className="bg-white rounded-lg shadow-md border-2 border-gray-200">
|
<div key={cveId} className="bg-white rounded-lg shadow-md border-2 border-gray-200">
|
||||||
<div className="p-6">
|
{/* Clickable CVE Header */}
|
||||||
{/* CVE Header */}
|
<div
|
||||||
<div className="mb-4">
|
className="p-6 cursor-pointer hover:bg-gray-50 transition-colors duration-200 select-none"
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{cveId}</h3>
|
onClick={() => toggleCVEExpand(cveId)}
|
||||||
<p className="text-gray-600 mb-3">{vendorEntries[0].description}</p>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-start justify-between">
|
||||||
<span>Published: {vendorEntries[0].published_date}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span>•</span>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
<ChevronDown
|
||||||
|
className={`w-5 h-5 text-gray-500 transition-transform duration-200 flex-shrink-0 ${isCVEExpanded ? 'rotate-0' : '-rotate-90'}`}
|
||||||
|
/>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900">{cveId}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsed: truncated description + summary row */}
|
||||||
|
{!isCVEExpanded && (
|
||||||
|
<div className="ml-8">
|
||||||
|
<p className="text-gray-600 text-sm truncate mb-2">{vendorEntries[0].description}</p>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${getSeverityColor(highestSeverity)}`}>
|
||||||
|
{highestSeverity}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
{totalDocCount} doc{totalDocCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{overallStatuses.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded: full description + metadata */}
|
||||||
|
{isCVEExpanded && (
|
||||||
|
<div className="ml-8">
|
||||||
|
<p className="text-gray-600 mb-3">{vendorEntries[0].description}</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>Published: {vendorEntries[0].published_date}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{vendorEntries.length} affected vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||||
|
{canWrite() && vendorEntries.length >= 2 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteEntireCVE(cveId, vendorEntries.length); }}
|
||||||
|
className="ml-2 px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors border border-red-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Delete All Vendors
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Vendor Entries */}
|
{/* Expanded: Vendor Entries */}
|
||||||
<div className="space-y-3">
|
{isCVEExpanded && (
|
||||||
{vendorEntries.map((cve) => {
|
<div className="px-6 pb-6">
|
||||||
const key = `${cve.cve_id}-${cve.vendor}`;
|
<div className="space-y-3">
|
||||||
const documents = cveDocuments[key] || [];
|
{vendorEntries.map((cve) => {
|
||||||
const isExpanded = selectedCVE === cve.cve_id && selectedVendorView === cve.vendor;
|
const key = `${cve.cve_id}-${cve.vendor}`;
|
||||||
|
const documents = cveDocuments[key] || [];
|
||||||
|
const isDocExpanded = selectedCVE === cve.cve_id && selectedVendorView === cve.vendor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cve.id} className="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
<div key={cve.id} className="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h4 className="text-lg font-semibold text-gray-900">{cve.vendor}</h4>
|
<h4 className="text-lg font-semibold text-gray-900">{cve.vendor}</h4>
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getSeverityColor(cve.severity)}`}>
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getSeverityColor(cve.severity)}`}>
|
||||||
{cve.severity}
|
{cve.severity}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${cve.doc_status === 'Complete' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
</div>
|
||||||
{cve.doc_status === 'Complete' ? '✓ Docs Complete' : '⚠ Incomplete'}
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
</span>
|
<span>Status: <span className="font-medium text-gray-700">{cve.status}</span></span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
{cve.document_count} document{cve.document_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
<div className="flex gap-2">
|
||||||
<span>Status: <span className="font-medium text-gray-700">{cve.status}</span></span>
|
<button
|
||||||
<span className="flex items-center gap-1">
|
onClick={() => handleViewDocuments(cve.cve_id, cve.vendor)}
|
||||||
<FileText className="w-4 h-4" />
|
className="px-4 py-2 text-[#0476D9] hover:bg-blue-50 rounded-lg transition-colors flex items-center gap-2 border border-[#0476D9]"
|
||||||
{cve.document_count} document{cve.document_count !== 1 ? 's' : ''}
|
>
|
||||||
</span>
|
<Eye className="w-4 h-4" />
|
||||||
|
{isDocExpanded ? 'Hide' : 'View'} Documents
|
||||||
|
</button>
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditCVE(cve)}
|
||||||
|
className="px-3 py-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors flex items-center gap-1 border border-orange-300"
|
||||||
|
title="Edit CVE entry"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteCVEEntry(cve)}
|
||||||
|
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors flex items-center gap-1 border border-red-300"
|
||||||
|
title="Delete this vendor entry"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleViewDocuments(cve.cve_id, cve.vendor)}
|
|
||||||
className="px-4 py-2 text-[#0476D9] hover:bg-blue-50 rounded-lg transition-colors flex items-center gap-2 border border-[#0476D9]"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
{isExpanded ? 'Hide' : 'View'} Documents
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documents Section */}
|
{/* Documents Section */}
|
||||||
{isExpanded && (
|
{isDocExpanded && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-300">
|
<div className="mt-4 pt-4 border-t border-gray-300">
|
||||||
<h5 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
<h5 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
Documents for {cve.vendor} ({documents.length})
|
Documents for {cve.vendor} ({documents.length})
|
||||||
</h5>
|
</h5>
|
||||||
{documents.length > 0 ? (
|
{documents.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{documents.map(doc => (
|
{documents.map(doc => (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="flex items-center justify-between p-3 bg-white rounded-lg hover:bg-gray-50 transition-colors border border-gray-200"
|
className="flex items-center justify-between p-3 bg-white rounded-lg hover:bg-gray-50 transition-colors border border-gray-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedDocuments.includes(doc.id)}
|
checked={selectedDocuments.includes(doc.id)}
|
||||||
onChange={() => toggleDocumentSelection(doc.id)}
|
onChange={() => toggleDocumentSelection(doc.id)}
|
||||||
className="w-4 h-4 text-[#0476D9] rounded focus:ring-2 focus:ring-[#0476D9]"
|
className="w-4 h-4 text-[#0476D9] rounded focus:ring-2 focus:ring-[#0476D9]"
|
||||||
/>
|
/>
|
||||||
<FileText className="w-5 h-5 text-gray-400" />
|
<FileText className="w-5 h-5 text-gray-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
|
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
|
||||||
<p className="text-xs text-gray-500 capitalize">
|
<p className="text-xs text-gray-500 capitalize">
|
||||||
{doc.type} • {doc.file_size}
|
{doc.type} • {doc.file_size}
|
||||||
{doc.notes && ` • ${doc.notes}`}
|
{doc.notes && ` • ${doc.notes}`}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<a
|
||||||
|
href={`${API_HOST}/${doc.file_path}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1 text-sm text-[#0476D9] hover:bg-blue-50 rounded transition-colors border border-[#0476D9]"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{isAdmin() && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteDocument(doc.id, cve.cve_id, cve.vendor)}
|
||||||
|
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors border border-red-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
))}
|
||||||
<a
|
</div>
|
||||||
href={`${API_HOST}/${doc.file_path}`}
|
) : (
|
||||||
target="_blank"
|
<p className="text-sm text-gray-500 italic">No documents attached yet</p>
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
className="px-3 py-1 text-sm text-[#0476D9] hover:bg-blue-50 rounded transition-colors border border-[#0476D9]"
|
{canWrite() && (
|
||||||
>
|
<button
|
||||||
View
|
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
|
||||||
</a>
|
disabled={uploadingFile}
|
||||||
{isAdmin() && (
|
className="mt-3 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 border border-gray-300"
|
||||||
<button
|
>
|
||||||
onClick={() => handleDeleteDocument(doc.id, cve.cve_id, cve.vendor)}
|
<Upload className="w-4 h-4" />
|
||||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors border border-red-600 flex items-center gap-1"
|
{uploadingFile ? 'Uploading...' : 'Upload Document'}
|
||||||
>
|
</button>
|
||||||
<Trash2 className="w-3 h-3" />
|
)}
|
||||||
Delete
|
</div>
|
||||||
</button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500 italic">No documents attached yet</p>
|
|
||||||
)}
|
|
||||||
{canWrite() && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleFileUpload(cve.cve_id, cve.vendor)}
|
|
||||||
disabled={uploadingFile}
|
|
||||||
className="mt-3 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50 border border-gray-300"
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4" />
|
|
||||||
{uploadingFile ? 'Uploading...' : 'Upload Document'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const ACTION_BADGES = {
|
|||||||
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||||
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||||
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||||
|
cve_edit: { bg: 'bg-orange-100', text: 'text-orange-800' },
|
||||||
|
cve_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'];
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
plan.md
Normal file
297
plan.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# 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
|
||||||
Reference in New Issue
Block a user