18 Commits

Author SHA1 Message Date
26abd55e0f added visual tweaks and document requirements REMOVED 2026-02-02 16:32:44 -07:00
eae4594baf added email extension to allowed list type 2026-02-02 16:11:43 -07:00
84803a353e added input validation and security hardening 2026-02-02 14:39:50 -07:00
d520c4ae41 Added tweaks to allow edits/deletes of cve and vendors or to fix typos 2026-02-02 11:33:44 -07:00
da109a6f8b Added NVD lookup features and optional NVD API key in .env file 2026-02-02 10:50:38 -07:00
260ae48f77 Merge master (audit logging) into feature/nvd-lookup 2026-02-02 10:08:37 -07:00
fbdf05392a Add NVD sync implementation plan
Detailed plan for the NVD lookup + retroactive sync feature
covering stash resolution, backend endpoints, frontend
NvdSyncModal component, and App.js integration.

Note: claude_status.md is gitignored but has been updated
locally with full session context including stash state,
conflict resolution steps, and task list. Copy it manually
to the offsite machine if needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:11:30 -07:00
1a578b23c1 Audit logging feature files 2026-01-29 15:10:29 -07:00
41c8a1ef27 added gitlab specific issue templates 2026-01-29 12:52:47 -07:00
8947a2864d Added database migration script 2026-01-29 11:07:49 -07:00
792467930d Updated README to reflect current changes and deployment 2026-01-29 10:43:06 -07:00
1a6b51dea3 Merge branch 'fix/issue-1' - stop tracking claude specific files
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:18:31 -07:00
836a9f3774 completed test plan - pass, modified setup.js 2026-01-29 03:47:15 +00:00
788ad389c4 Add setup-env.sh script for environment configuration
Creates interactive setup script that configures .env files for both
frontend and backend with the correct server IP address. Features:
- Auto-detects current server IP
- Prompts for custom IP if needed
- Checks for existing .env files before overwriting
- Configures REACT_APP_API_BASE, REACT_APP_API_HOST, and CORS settings

This prevents the issue where React apps start with localhost fallback
when .env files are missing or created after server startup.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 03:13:56 +00:00
38dcbb1122 changed stop-servers to better kill the process on updates and added test cases for feature 2026-01-28 15:31:13 -07:00
696569e6c7 Changed browsar tab to read dashboard instead of ReactApp 2026-01-28 14:44:51 -07:00
da14c92d98 added required code changes, components, and packages for login feature 2026-01-28 14:36:33 -07:00
3eb608617c Stopped tracking claude specific files 2026-01-28 13:46:32 -07:00
37 changed files with 5798 additions and 378 deletions

89
.claude/agents/backend.md Normal file
View 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
View 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
View 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.

View 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

7
.gitignore vendored
View File

@@ -37,3 +37,10 @@ frontend.pid
# Temporary files
backend/uploads/temp/
claude.md
claude_status.md
feature_request*.md
backend/add_vendor_to_documents.js
backend/fix_multivendor_constraint.js
backend/server.js-backup
backend/setup.js-backup

View File

@@ -0,0 +1,35 @@
<!-- Labels: kind/bug, status/triage -->
Please provide as much detail as possible so we can reproduce and fix the issue!
## Description
<!-- What is currently happening? -->
## Steps to Reproduce
<!--
1. Go to '...'
2. Click on '...'
3. See error
-->
1.
2.
3.
## Environment
<!-- Which browser/OS are you using? (e.g., Chrome on Windows 11) -->
## Relevant Log Output
<!-- Please paste any error logs or console output here. -->
```
(paste logs here)
```

View File

@@ -0,0 +1,27 @@
<!-- Labels: kind/feature, status/triage -->
Thanks for taking the time to suggest a new feature!
## Is your feature request related to a problem?
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
## Describe the solution you'd like
<!-- A clear and concise description of what you want to happen. -->
## Describe alternatives you've considered
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
## Additional context
<!-- Add any other context or screenshots about the feature request here. -->

572
README.md
View File

@@ -3,7 +3,7 @@
A comprehensive vulnerability management system designed for tracking CVE (Common Vulnerabilities and Exposures) remediation status and maintaining vendor documentation compliance.
![Charter Communications](https://img.shields.io/badge/Charter-Communications-0476D9)
![Version](https://img.shields.io/badge/version-1.0.0-blue)
![Version](https://img.shields.io/badge/version-1.1.0-blue)
![License](https://img.shields.io/badge/license-Internal-red)
---
@@ -51,16 +51,32 @@ This dashboard provides:
## ✨ Key Features
### 🔐 User Authentication & Roles
- **Secure login**: Session-based authentication with encrypted passwords
- **Role-based access control**: Three user roles with different permissions
- **Admin**: Full access including user management and document deletion
- **Editor**: Can add/edit CVEs and upload documents
- **Viewer**: Read-only access to CVEs and documents
- **User management**: Admins can create, edit, and deactivate users
- **Session persistence**: Stay logged in across browser sessions (24-hour expiry)
### 🔍 Quick CVE Status Check
- **Instant verification**: Enter any CVE ID and immediately see if it's been addressed
- **Document compliance**: Shows which documents are present (Advisory ✓, Email ○, Screenshot ○)
- **Multi-vendor display**: Shows all vendors associated with a CVE
- **Document compliance**: Shows which documents are present per vendor (Advisory ✓, Email ○, Screenshot ○)
- **Visual indicators**: Color-coded results (green = addressed, yellow = not found, red = missing required docs)
### 📂 Document Management
- **Upload documents**: PDF, images, Word docs, text files (up to 10MB)
- **Automatic organization**: Files stored as `uploads/CVE-2024-1234/Microsoft/advisory.pdf`
- **Per-vendor storage**: Each vendor's documents are organized separately
- **Document types**: Advisory, Email, Screenshot, Patch, Other
- **View & Delete**: Direct links to view documents, delete with confirmation
- **View & Delete**: Direct links to view documents, admin-only deletion
### 🏢 Multi-Vendor Support
- **Same CVE, multiple vendors**: Track a single CVE across different vendors (e.g., CVE-2024-1234 for both Microsoft and Cisco)
- **Vendor-specific tracking**: Each vendor entry has its own status, documents, and compliance
- **Flexible organization**: Documents organized by CVE ID and vendor
### 🔎 Search & Filter
- **Search by CVE ID or description**: Find vulnerabilities quickly
@@ -72,6 +88,7 @@ This dashboard provides:
- **Document status badges**: "✓ Docs Complete" or "⚠ Incomplete"
- **Required documents**: Advisory (mandatory), Email (optional), Screenshot (optional)
- **Vendor-specific requirements**: Customizable per vendor
- **Per-vendor compliance**: Track documentation status for each vendor separately
### 🎨 Charter/Spectrum Branding
- **Corporate colors**: Charter Blue (#0476D9) throughout
@@ -90,12 +107,15 @@ This dashboard provides:
│ │ Frontend │ │ Backend API │ │
│ │ │ HTTP │ │ │
│ │ React + │◄───────►│ Express.js │ │
│ │ Tailwind │ :3001 │ │ │
│ │ │ │ ┌─────────────────┐ │ │
│ │ Port: 3000 │ │ │ SQLite DB │ │ │
│ └──────────────┘ │ │ - cves │ │ │
│ │ Tailwind │ :3001 │ + Auth Middleware │ │
│ │ │ │ │ │
│ │ Port: 3000 │ │ ┌─────────────────┐ │ │
│ └──────────────┘ │ │ SQLite DB │ │ │
│ │ │ - cves │ │ │
│ │ │ - documents │ │ │
│ │ │ - required_docs│ │ │
│ │ │ - users │ │ │
│ │ │ - sessions │ │ │
│ │ └─────────────────┘ │ │
│ └──────────────────────┘ │
│ │ │
@@ -105,9 +125,10 @@ This dashboard provides:
│ │ │ │
│ │ uploads/ │ │
│ │ └─ CVE-2024-1234/ │ │
│ │ ─ Microsoft/ │ │
│ │ ─ advisory.pdf│ │
│ │ └─ email.pdf │ │
│ │ ─ Microsoft/ │ │
│ │ ─ advisory.pdf│ │
│ │ └─ Cisco/ │ │
│ │ └─ advisory.pdf│ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
@@ -119,6 +140,7 @@ This dashboard provides:
- Tailwind CSS (via CDN)
- Lucide React (icons)
- Fetch API
- Context API (AuthContext)
**Backend:**
- Node.js v18+
@@ -126,6 +148,9 @@ This dashboard provides:
- SQLite3
- Multer (file uploads)
- CORS
- bcryptjs (password hashing)
- cookie-parser (session management)
- dotenv (environment configuration)
**Database:**
- SQLite (development/production)
@@ -168,6 +193,9 @@ Expected packages:
- sqlite3
- multer
- cors
- bcryptjs
- cookie-parser
- dotenv
### 3. Install Frontend Dependencies
```bash
@@ -189,35 +217,64 @@ node setup.js
This will:
- ✅ Create `cve_database.db`
- ✅ Create tables: `cves`, `documents`, `required_documents`
- ✅ Create tables: `cves`, `documents`, `required_documents`, `users`, `sessions`
- ✅ Set up multi-vendor support with UNIQUE(cve_id, vendor) constraint
- ✅ Create indexes for fast queries
- ✅ Create `cve_document_status` view
- ✅ Create `uploads/` and `uploads/temp/` directories
- ✅ Create `uploads/` directory
- ✅ Insert default required documents for major vendors
- ✅ Create default admin user (admin/admin123)
Expected output:
```
🚀 CVE Database Setup
🚀 CVE Database Setup (Multi-Vendor Support)
════════════════════════════════════════
✓ Created uploads directory
✓ Uploads directory already exists
✓ Database initialized successfully
Database connection closed
Created default admin user (admin/admin123)
📝 Adding sample CVE data for testing...
✓ Added sample: CVE-2024-SAMPLE-1 / Microsoft
✓ Added sample: CVE-2024-SAMPLE-1 / Cisco
Sample data added - demonstrates multi-vendor support
╔════════════════════════════════════════════════════════╗
║ CVE DATABASE SETUP COMPLETE! ║
╚════════════════════════════════════════════════════════╝
```
### 5. Configure Server IP
### 5. Configure Environment Variables
Edit `frontend/src/App.js` and update the API URL (line 5):
```javascript
const API_BASE = 'http://YOUR_SERVER_IP:3001/api';
Run the environment setup script to configure server IP addresses:
```bash
cd backend
chmod +x setup-env.sh
./setup-env.sh
```
Example:
```javascript
const API_BASE = 'http://192.168.2.117:3001/api';
The script will:
- Auto-detect your server's IP address
- Create `backend/.env` with CORS and API settings
- Create `frontend/.env` with API base URL
**Manual Configuration (Alternative):**
Create `backend/.env`:
```bash
# Backend Configuration
PORT=3001
API_HOST=YOUR_SERVER_IP
CORS_ORIGINS=http://YOUR_SERVER_IP:3000
SESSION_SECRET=your-secure-secret-key
```
Create `frontend/.env`:
```bash
# Frontend Configuration
REACT_APP_API_BASE=http://YOUR_SERVER_IP:3001/api
REACT_APP_API_HOST=http://YOUR_SERVER_IP:3001
```
### 6. Add Tailwind CSS to Frontend
@@ -292,32 +349,28 @@ chmod +x stop-servers.sh
### Backend Configuration
**CORS Settings** (`backend/server.js`):
```javascript
app.use(cors({
origin: ['http://localhost:3000', 'http://192.168.2.117:3000'],
credentials: true
}));
**Environment Variables** (`backend/.env`):
```bash
PORT=3001 # API server port
API_HOST=192.168.2.117 # Server IP address
CORS_ORIGINS=http://192.168.2.117:3000 # Allowed frontend origins (comma-separated)
SESSION_SECRET=your-secure-secret # Session encryption key
```
**File Upload Limits** (`backend/server.js`):
```javascript
const upload = multer({
const upload = multer({
storage: storage,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
});
```
**Port Configuration** (`backend/server.js`):
```javascript
const PORT = 3001;
```
### Frontend Configuration
**API Base URL** (`frontend/src/App.js`):
```javascript
const API_BASE = 'http://192.168.2.117:3001/api';
**Environment Variables** (`frontend/.env`):
```bash
REACT_APP_API_BASE=http://192.168.2.117:3001/api # API endpoint with /api path
REACT_APP_API_HOST=http://192.168.2.117:3001 # Base URL for file downloads
```
**Severity Levels** (`frontend/src/App.js`):
@@ -372,8 +425,36 @@ cd /home/cve-dashboard
- Frontend: `http://YOUR_SERVER_IP:3000`
- Backend API: `http://YOUR_SERVER_IP:3001`
### Logging In
1. Navigate to `http://YOUR_SERVER_IP:3000`
2. You'll see the login page
3. Enter credentials:
- **Default admin**: username `admin`, password `admin123`
4. Click **"Sign In"**
5. You'll be redirected to the dashboard
**First-Time Setup:**
- After initial setup, change the default admin password
- Create additional users based on their roles:
- **Viewers**: Read-only access (security auditors, stakeholders)
- **Editors**: Can add/edit CVEs and upload documents (analysts)
- **Admins**: Full access including user management (team leads)
### User Management (Admin Only)
1. Click on your username in the top right
2. Select **"User Management"**
3. From here you can:
- View all users and their roles
- Create new users
- Edit user roles and status
- Deactivate users (soft delete)
### Adding a New CVE
**Required Role:** Editor or Admin
1. Click the **"+ Add New CVE"** button (top right)
2. Fill in the form:
- **CVE ID**: e.g., `CVE-2024-1234`
@@ -384,25 +465,31 @@ cd /home/cve-dashboard
3. Click **"Add CVE"**
4. CVE appears in the dashboard immediately
**Multi-Vendor Note:** You can add the same CVE ID multiple times with different vendors. For example, CVE-2024-1234 can exist for both Microsoft and Cisco with separate tracking.
### Uploading Documents
**Required Role:** Editor or Admin
1. Find the CVE in the list
2. Click **"View Documents"** to expand
3. Click **"Upload New Document"**
4. Select your file (PDF, PNG, JPG, TXT, DOC, DOCX)
5. When prompted, specify:
- **Vendor**: Select the vendor this document applies to
- **Document type**: advisory, email, screenshot, patch, other
- **Notes** (optional): Description or context
6. File uploads and organizes automatically
**File Organization Example:**
**File Organization Example (Multi-Vendor):**
```
uploads/
└── CVE-2024-1234/
── Microsoft/
├── 1706140800000-MS-Security-Advisory.pdf
── 1706140850000-Vendor-Email.pdf
└── 1706140900000-Patch-Screenshot.png
── Microsoft/
├── 1706140800000-MS-Security-Advisory.pdf
── 1706140850000-Vendor-Email.pdf
└── Cisco/
└── 1706140900000-Cisco-Advisory.pdf
```
### Using Quick Check
@@ -412,16 +499,21 @@ uploads/
1. Enter `CVE-2024-5678` in the **Quick Check** box
2. Click **"Check Status"**
**Result A - Already Addressed:**
**Result A - Already Addressed (Multi-Vendor):**
```
✓ CVE Addressed
Vendor: Cisco
Severity: High
Status: Addressed
Documents: 2 attached
✓ Advisory ✓ Email ○ Screenshot
Ready for false positive request
Vendor: Microsoft
Severity: Critical | Status: Addressed
Documents: 3 attached
✓ Advisory ✓ Email ✓ Screenshot
Vendor: Cisco
Severity: High | Status: Open
Documents: 1 attached
✓ Advisory ○ Email ○ Screenshot
Ready for false positive request (Microsoft)
```
**Result B - Not Found:**
@@ -436,6 +528,7 @@ Action Required: Create entry and gather vendor documentation
**Result C - Incomplete:**
```
✓ CVE Addressed
Vendor: Oracle
Documents: 1 attached
✗ Advisory ○ Email ○ Screenshot
@@ -468,10 +561,10 @@ Missing required advisory - obtain before requesting false positive
3. Click **"View"** to open document in new tab
4. Select checkboxes to export multiple documents
### Deleting Documents
### Deleting Documents (Admin Only)
1. Expand documents for a CVE
2. Click red **"Delete"** button next to document
2. Click red **"Delete"** button next to document (only visible to admins)
3. Confirm deletion in popup
4. Document removed from database and filesystem
@@ -488,12 +581,107 @@ Missing required advisory - obtain before requesting false positive
Base URL: `http://YOUR_SERVER_IP:3001/api`
**Authentication Required:** All endpoints except `/api/auth/login` require authentication via session cookie.
### Authentication Endpoints
#### Login
```http
POST /api/auth/login
Content-Type: application/json
```
**Body:**
```json
{
"username": "admin",
"password": "admin123"
}
```
**Response:**
```json
{
"message": "Login successful",
"user": {
"id": 1,
"username": "admin",
"email": "admin@localhost",
"role": "admin"
}
}
```
Sets a session cookie (`session_id`) for subsequent requests.
#### Logout
```http
POST /api/auth/logout
```
**Response:**
```json
{
"message": "Logged out successfully"
}
```
#### Get Current User
```http
GET /api/auth/me
```
**Response:**
```json
{
"id": 1,
"username": "admin",
"email": "admin@localhost",
"role": "admin"
}
```
### User Management Endpoints (Admin Only)
#### Get All Users
```http
GET /api/users
```
#### Create User
```http
POST /api/users
Content-Type: application/json
```
**Body:**
```json
{
"username": "newuser",
"email": "user@example.com",
"password": "password123",
"role": "editor"
}
```
#### Update User
```http
PUT /api/users/:id
Content-Type: application/json
```
#### Delete User
```http
DELETE /api/users/:id
```
### CVE Endpoints
#### Get All CVEs
```http
GET /api/cves
```
**Required Role:** Any authenticated user
**Query Parameters:**
- `search` (optional): Search term for CVE ID or description
@@ -502,7 +690,7 @@ GET /api/cves
**Example:**
```bash
curl "http://192.168.2.117:3001/api/cves?vendor=Microsoft&severity=Critical"
curl -b cookies.txt "http://192.168.2.117:3001/api/cves?vendor=Microsoft&severity=Critical"
```
**Response:**
@@ -528,33 +716,43 @@ curl "http://192.168.2.117:3001/api/cves?vendor=Microsoft&severity=Critical"
```http
GET /api/cves/check/:cveId
```
**Required Role:** Any authenticated user
**Example:**
```bash
curl "http://192.168.2.117:3001/api/cves/check/CVE-2024-1234"
curl -b cookies.txt "http://192.168.2.117:3001/api/cves/check/CVE-2024-1234"
```
**Response (Found):**
**Response (Found - Multi-Vendor):**
```json
{
"exists": true,
"cve": {
"cve_id": "CVE-2024-1234",
"vendor": "Microsoft",
"severity": "Critical",
"status": "Addressed",
"total_documents": 3,
"has_advisory": 1,
"has_email": 1,
"has_screenshot": 1
},
"vendors": [
{
"vendor": "Microsoft",
"severity": "Critical",
"status": "Addressed",
"total_documents": 3,
"compliance": {
"advisory": true,
"email": true,
"screenshot": true
}
},
{
"vendor": "Cisco",
"severity": "High",
"status": "Open",
"total_documents": 1,
"compliance": {
"advisory": true,
"email": false,
"screenshot": false
}
}
],
"addressed": true,
"has_required_docs": true,
"compliance": {
"advisory": true,
"email": true,
"screenshot": true
}
"has_required_docs": true
}
```
@@ -566,11 +764,43 @@ curl "http://192.168.2.117:3001/api/cves/check/CVE-2024-1234"
}
```
#### Get Vendors for CVE
```http
GET /api/cves/:cveId/vendors
```
**Required Role:** Any authenticated user
**Example:**
```bash
curl -b cookies.txt "http://192.168.2.117:3001/api/cves/CVE-2024-1234/vendors"
```
**Response:**
```json
[
{
"vendor": "Microsoft",
"severity": "Critical",
"status": "Addressed",
"description": "Remote code execution vulnerability",
"published_date": "2024-01-15"
},
{
"vendor": "Cisco",
"severity": "High",
"status": "Open",
"description": "Remote code execution vulnerability",
"published_date": "2024-01-15"
}
]
```
#### Create CVE
```http
POST /api/cves
Content-Type: application/json
```
**Required Role:** Editor or Admin
**Body:**
```json
@@ -583,9 +813,11 @@ Content-Type: application/json
}
```
**Note:** The same CVE ID can be added multiple times with different vendors. The combination of (cve_id, vendor) must be unique.
**Example:**
```bash
curl -X POST http://192.168.2.117:3001/api/cves \
curl -b cookies.txt -X POST http://192.168.2.117:3001/api/cves \
-H "Content-Type: application/json" \
-d '{
"cve_id": "CVE-2024-1234",
@@ -601,7 +833,14 @@ curl -X POST http://192.168.2.117:3001/api/cves \
{
"id": 1,
"cve_id": "CVE-2024-1234",
"message": "CVE created successfully"
"message": "CVE created successfully for vendor: Microsoft"
}
```
**Error (Duplicate):**
```json
{
"error": "This CVE already exists for this vendor. Choose a different vendor or update the existing entry."
}
```
@@ -610,6 +849,7 @@ curl -X POST http://192.168.2.117:3001/api/cves \
PATCH /api/cves/:cveId/status
Content-Type: application/json
```
**Required Role:** Editor or Admin
**Body:**
```json
@@ -620,7 +860,7 @@ Content-Type: application/json
**Example:**
```bash
curl -X PATCH http://192.168.2.117:3001/api/cves/CVE-2024-1234/status \
curl -b cookies.txt -X PATCH http://192.168.2.117:3001/api/cves/CVE-2024-1234/status \
-H "Content-Type: application/json" \
-d '{"status": "False Positive Requested"}'
```
@@ -631,10 +871,14 @@ curl -X PATCH http://192.168.2.117:3001/api/cves/CVE-2024-1234/status \
```http
GET /api/cves/:cveId/documents
```
**Required Role:** Any authenticated user
**Query Parameters:**
- `vendor` (optional): Filter documents by vendor
**Example:**
```bash
curl "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents"
curl -b cookies.txt "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents?vendor=Microsoft"
```
**Response:**
@@ -643,6 +887,7 @@ curl "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents"
{
"id": 1,
"cve_id": "CVE-2024-1234",
"vendor": "Microsoft",
"name": "MS-Security-Advisory.pdf",
"type": "advisory",
"file_path": "uploads/CVE-2024-1234/Microsoft/1706140800000-MS-Security-Advisory.pdf",
@@ -659,19 +904,18 @@ curl "http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents"
POST /api/cves/:cveId/documents
Content-Type: multipart/form-data
```
**Required Role:** Editor or Admin
**Form Fields:**
- `file`: The file to upload
- `cveId`: CVE ID (e.g., CVE-2024-1234)
- `vendor`: Vendor name (e.g., Microsoft)
- `vendor`: Vendor name (required - determines storage folder)
- `type`: Document type (advisory, email, screenshot, patch, other)
- `notes` (optional): Description
**Example:**
```bash
curl -X POST http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents \
curl -b cookies.txt -X POST http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents \
-F "file=@/path/to/advisory.pdf" \
-F "cveId=CVE-2024-1234" \
-F "vendor=Microsoft" \
-F "type=advisory" \
-F "notes=Official security advisory"
@@ -694,10 +938,11 @@ curl -X POST http://192.168.2.117:3001/api/cves/CVE-2024-1234/documents \
```http
DELETE /api/documents/:id
```
**Required Role:** Admin only
**Example:**
```bash
curl -X DELETE http://192.168.2.117:3001/api/documents/1
curl -b cookies.txt -X DELETE http://192.168.2.117:3001/api/documents/1
```
**Response:**
@@ -713,10 +958,11 @@ curl -X DELETE http://192.168.2.117:3001/api/documents/1
```http
GET /api/vendors
```
**Required Role:** Any authenticated user
**Example:**
```bash
curl "http://192.168.2.117:3001/api/vendors"
curl -b cookies.txt "http://192.168.2.117:3001/api/vendors"
```
**Response:**
@@ -728,10 +974,11 @@ curl "http://192.168.2.117:3001/api/vendors"
```http
GET /api/stats
```
**Required Role:** Any authenticated user
**Example:**
```bash
curl "http://192.168.2.117:3001/api/stats"
curl -b cookies.txt "http://192.168.2.117:3001/api/stats"
```
**Response:**
@@ -757,7 +1004,7 @@ Stores CVE metadata and remediation status.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
| cve_id | VARCHAR(20) UNIQUE | CVE identifier (e.g., CVE-2024-1234) |
| cve_id | VARCHAR(20) | CVE identifier (e.g., CVE-2024-1234) |
| vendor | VARCHAR(100) | Vendor name |
| severity | VARCHAR(20) | Critical, High, Medium, Low |
| description | TEXT | Vulnerability description |
@@ -766,6 +1013,8 @@ Stores CVE metadata and remediation status.
| created_at | TIMESTAMP | Record creation timestamp |
| updated_at | TIMESTAMP | Last update timestamp |
**Unique Constraint:** `UNIQUE(cve_id, vendor)` - Allows same CVE with different vendors
**Indexes:**
- `idx_cve_id` on `cve_id`
- `idx_vendor` on `vendor`
@@ -778,7 +1027,8 @@ Stores document metadata and file locations.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
| cve_id | VARCHAR(20) | Foreign key to cves.cve_id |
| cve_id | VARCHAR(20) | CVE identifier |
| vendor | VARCHAR(100) | Vendor name (for per-vendor organization) |
| name | VARCHAR(255) | Original filename |
| type | VARCHAR(50) | advisory, email, screenshot, patch, other |
| file_path | VARCHAR(500) | Path to file on filesystem |
@@ -791,8 +1041,49 @@ Stores document metadata and file locations.
**Indexes:**
- `idx_doc_cve_id` on `cve_id`
- `idx_doc_vendor` on `vendor`
- `idx_doc_type` on `type`
#### `users`
Stores user accounts for authentication.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
| username | VARCHAR(50) UNIQUE | Login username |
| email | VARCHAR(255) UNIQUE | User email address |
| password_hash | VARCHAR(255) | bcrypt hashed password |
| role | VARCHAR(20) | admin, editor, or viewer |
| is_active | BOOLEAN | Account active status (1=active, 0=disabled) |
| created_at | TIMESTAMP | Account creation timestamp |
| last_login | TIMESTAMP | Last successful login |
**Roles:**
- `admin` - Full access: manage users, delete documents, all CVE operations
- `editor` - Can add/edit CVEs, upload documents
- `viewer` - Read-only access to CVEs and documents
**Indexes:**
- `idx_users_username` on `username`
#### `sessions`
Stores active user sessions.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PRIMARY KEY | Auto-incrementing ID |
| session_id | VARCHAR(255) UNIQUE | Session token (stored in cookie) |
| user_id | INTEGER | Foreign key to users.id |
| expires_at | TIMESTAMP | Session expiration time |
| created_at | TIMESTAMP | Session creation timestamp |
**Foreign Key:** `user_id``users(id)` ON DELETE CASCADE
**Indexes:**
- `idx_sessions_session_id` on `session_id`
- `idx_sessions_user_id` on `user_id`
- `idx_sessions_expires` on `expires_at`
#### `required_documents`
Defines which document types are mandatory per vendor.
@@ -872,36 +1163,53 @@ cve-dashboard/
├── backend/
│ ├── server.js # Express API server
│ ├── setup.js # Database initialization script
│ ├── cve_database.db # SQLite database file
│ ├── package.json # Backend dependencies
── backend.log # Backend log file (if using startup script)
│ ├── setup-env.sh # Environment configuration script
│ ├── .env # Environment variables (create with setup-env.sh)
── cve_database.db # SQLite database file
│ ├── package.json # Backend dependencies
│ ├── middleware/
│ │ └── auth.js # Authentication middleware
│ ├── routes/
│ │ ├── auth.js # Login/logout endpoints
│ │ └── users.js # User management endpoints
│ └── backend.log # Backend log file (if using startup script)
├── frontend/
│ ├── public/
│ │ └── index.html # Main HTML (includes Tailwind CDN)
│ │ └── index.html # Main HTML (includes Tailwind CDN)
│ ├── src/
│ │ ├── App.js # Main React component
│ │ ├── index.js # React entry point
│ │ ── index.css # Global styles
│ ├── package.json # Frontend dependencies
└── frontend.log # Frontend log file (if using startup script)
│ │ ├── App.js # Main React component
│ │ ├── index.js # React entry point
│ │ ── index.css # Global styles
│ ├── components/
├── LoginForm.js # Login page component
│ │ │ ├── UserMenu.js # User dropdown menu
│ │ │ └── UserManagement.js # Admin user management
│ │ └── contexts/
│ │ └── AuthContext.js # Authentication state management
│ ├── .env # Environment variables (create with setup-env.sh)
│ ├── package.json # Frontend dependencies
│ └── frontend.log # Frontend log file (if using startup script)
├── uploads/ # File storage (auto-created)
│ ├── temp/ # Temporary upload directory
├── uploads/ # File storage (auto-created)
│ ├── temp/ # Temporary upload directory
│ ├── CVE-2024-1234/
│ │ ── Microsoft/
│ │ ├── 1706140800000-advisory.pdf
│ │ └── 1706140850000-email.pdf
│ │ ── Microsoft/ # Vendor-specific folder
│ │ ├── 1706140800000-advisory.pdf
│ │ └── 1706140850000-email.pdf
│ │ └── Cisco/ # Same CVE, different vendor
│ │ └── 1706140900000-advisory.pdf
│ └── CVE-2024-5678/
│ └── Cisco/
│ └── Oracle/
│ └── 1706140900000-advisory.pdf
├── .gitignore # Git ignore rules
├── README.md # This file
├── start-servers.sh # Startup script
├── stop-servers.sh # Shutdown script
├── backend.pid # Backend process ID (when running)
── frontend.pid # Frontend process ID (when running)
├── .gitignore # Git ignore rules
├── README.md # This file
├── test_cases_auth.md # Authentication test cases
├── start-servers.sh # Startup script
├── stop-servers.sh # Shutdown script
── backend.pid # Backend process ID (when running)
└── frontend.pid # Frontend process ID (when running)
```
### File Naming Convention
@@ -1104,8 +1412,10 @@ chmod -R 777 /home/cve-dashboard/uploads
## 🗺️ Roadmap
### Version 1.1 (Next Release)
- [ ] **User Authentication**: Login system with user roles
### Version 1.1 (Current Release)
- [x] **User Authentication**: Login system with user roles (admin, editor, viewer)
- [x] **Multi-Vendor Support**: Same CVE can be tracked across multiple vendors
- [x] **Environment Configuration**: .env files replace hardcoded IPs
- [ ] **Audit Logging**: Track who added/modified CVEs
- [ ] **Email Notifications**: Alert when new CVEs are added
- [ ] **Export to Excel**: Download CVE list as spreadsheet
@@ -1232,16 +1542,16 @@ Vulnerability Management Team
This software is proprietary and confidential. Unauthorized copying, distribution, or use of this software, via any medium, is strictly prohibited.
Copyright © 2024 Charter Communications. All rights reserved.
Copyright © 2024-2026 Charter Communications. All rights reserved.
---
## 📊 Project Statistics
- **Version**: 1.0.0
- **Released**: January 2024
- **Lines of Code**: ~1,500
- **Dependencies**: 12
- **Version**: 1.1.0
- **Released**: January 2026
- **Lines of Code**: ~2,500
- **Dependencies**: 15
- **Supported Browsers**: Chrome, Edge, Firefox, Safari
---
@@ -1273,6 +1583,40 @@ Copyright © 2024 Charter Communications. All rights reserved.
## 📝 Changelog
### [1.1.0] - 2026-01-29
#### Added
- **User Authentication**: Complete login system with session-based auth
- Three user roles: admin, editor, viewer
- Default admin account (admin/admin123)
- Session persistence with secure cookies
- Password hashing with bcryptjs
- **User Management**: Admin interface for managing users
- Create, edit, deactivate users
- Role assignment
- Password reset capability
- **Multi-Vendor Support**: Track same CVE across multiple vendors
- UNIQUE constraint on (cve_id, vendor) instead of just cve_id
- Per-vendor document storage
- Quick Check shows all vendors for a CVE
- New API endpoint: GET /api/cves/:cveId/vendors
- **Environment Configuration**: Replaced hardcoded IPs
- setup-env.sh script for easy configuration
- .env files for both frontend and backend
- Auto-detection of server IP address
#### Changed
- All API endpoints now require authentication
- Document deletion restricted to admin role
- CVE creation/editing restricted to editor and admin roles
- stop-servers.sh improved with better process killing
- Browser tab title changed from "ReactApp" to "Dashboard"
- Document storage now organized by CVE ID AND vendor
#### Fixed
- Dynamic hostname detection now works via environment variables
- Multiple vendors can now have entries for the same CVE
### [1.0.0] - 2024-01-26
#### Added
@@ -1288,9 +1632,9 @@ Copyright © 2024 Charter Communications. All rights reserved.
- Document compliance tracking
- Required document configuration per vendor
#### Known Issues
- Dynamic hostname detection not working (hardcoded IP as workaround)
- No user authentication (single-user system)
#### Known Issues (Resolved in 1.1.0)
- ~~Dynamic hostname detection not working (hardcoded IP as workaround)~~ Fixed
- ~~No user authentication (single-user system)~~ Fixed
- Export functionality shows alert only (not implemented)
---

222
TEST_PLAN_AUDIT_LOG.md Normal file
View File

@@ -0,0 +1,222 @@
# Audit Logging Feature - User Acceptance Test Plan
## Test Environment Setup
**Prerequisites:**
- Fresh database via `node backend/setup.js`, OR existing database migrated via `node backend/migrate-audit-log.js`
- Backend running on port 3001
- Frontend running on port 3000
- Three test accounts created:
- `admin` / `admin123` (role: admin)
- `editor1` (role: editor)
- `viewer1` (role: viewer)
**Verify setup:** Run `sqlite3 backend/cve_database.db ".tables"` and confirm `audit_logs` is listed.
---
## 1. Database & Schema
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 1.1 | Fresh install creates table | Run `node setup.js` on a new DB. Query `SELECT sql FROM sqlite_master WHERE name='audit_logs'` | Table exists with columns: id, user_id, username, action, entity_type, entity_id, details, ip_address, created_at | |
| 1.2 | Indexes created | Query `SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_audit%'` | Four indexes: idx_audit_user_id, idx_audit_action, idx_audit_entity_type, idx_audit_created_at | |
| 1.3 | Migration is idempotent | Run `node migrate-audit-log.js` twice on the same DB | Second run prints "already exists, nothing to do". No errors. Backup file created each run. | |
| 1.4 | Migration backs up DB | Run `node migrate-audit-log.js` | Backup file `cve_database_backup_<timestamp>.db` created in backend directory | |
| 1.5 | Setup summary updated | Run `node setup.js` | Console output lists `audit_logs` in the tables line | |
---
## 2. Authentication Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 2.1 | Successful login logged | Log in as `admin`. Query `SELECT * FROM audit_logs WHERE action='login' ORDER BY id DESC LIMIT 1` | Row with user_id=admin's ID, username='admin', action='login', entity_type='auth', details contains `{"role":"admin"}`, ip_address populated | |
| 2.2 | Failed login - wrong password | Attempt login with `admin` / `wrongpass`. Query audit_logs. | Row with action='login_failed', username='admin', details contains `{"reason":"invalid_password"}` | |
| 2.3 | Failed login - unknown user | Attempt login with `nonexistent` / `anypass`. Query audit_logs. | Row with action='login_failed', user_id=NULL, username='nonexistent', details contains `{"reason":"user_not_found"}` | |
| 2.4 | Failed login - disabled account | Disable a user account via admin, then attempt login as that user. Query audit_logs. | Row with action='login_failed', details contains `{"reason":"account_disabled"}` | |
| 2.5 | Logout logged | Log in as admin, then log out. Query audit_logs. | Row with action='logout', entity_type='auth', username='admin' | |
| 2.6 | Login does not block on audit error | Verify login succeeds even if audit_logs table had issues (non-critical path) | Login response returns normally regardless of audit insert result | |
---
## 3. CVE Operation Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 3.1 | CVE create logged | Log in as editor or admin. Add a new CVE (e.g., CVE-2025-TEST-1 / Microsoft / Critical). Query audit_logs. | Row with action='cve_create', entity_type='cve', entity_id='CVE-2025-TEST-1', details contains `{"vendor":"Microsoft","severity":"Critical"}` | |
| 3.2 | CVE status update logged | Update a CVE's status to "Addressed" via the API (`PATCH /api/cves/CVE-2025-TEST-1/status`). Query audit_logs. | Row with action='cve_update_status', entity_id='CVE-2025-TEST-1', details contains `{"status":"Addressed"}` | |
| 3.3 | CVE status update bug fix | Update a CVE's status. Verify the CVE record in the `cves` table. | Status is correctly updated. No SQL error (the old `vendor` reference bug is fixed). | |
| 3.4 | Audit captures acting user | Log in as `editor1`, create a CVE. Query audit_logs. | username='editor1' on the cve_create row | |
---
## 4. Document Operation Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 4.1 | Document upload logged | Upload a document to a CVE via the UI. Query audit_logs. | Row with action='document_upload', entity_type='document', entity_id=CVE ID, details contains vendor, type, and filename | |
| 4.2 | Document delete logged | Delete a document (admin only) via the UI. Query audit_logs. | Row with action='document_delete', entity_type='document', entity_id=document DB ID, details contains file_path | |
| 4.3 | Upload captures file metadata | Upload a file named `advisory.pdf` of type `advisory` for vendor `Cisco`. Query audit_logs. | details = `{"vendor":"Cisco","type":"advisory","filename":"advisory.pdf"}` | |
---
## 5. User Management Audit Logging
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 5.1 | User create logged | As admin, create a new user `testuser` with role `viewer`. Query audit_logs. | Row with action='user_create', entity_type='user', entity_id=new user's ID, details contains `{"created_username":"testuser","role":"viewer"}` | |
| 5.2 | User update logged | As admin, change `testuser`'s role to `editor`. Query audit_logs. | Row with action='user_update', entity_id=testuser's ID, details contains `{"role":"editor"}` | |
| 5.3 | User update - password change | As admin, change `testuser`'s password. Query audit_logs. | details contains `{"password_changed":true}` (password itself is NOT logged) | |
| 5.4 | User update - multiple fields | Change username and role at the same time. Query audit_logs. | details contains both changed fields | |
| 5.5 | User delete logged | As admin, delete `testuser`. Query audit_logs. | Row with action='user_delete', details contains `{"deleted_username":"testuser"}` | |
| 5.6 | User deactivation logged | As admin, set a user's is_active to false. Query audit_logs. | Row with action='user_update', details contains `{"is_active":false}` | |
| 5.7 | Self-delete prevented, no log | As admin, attempt to delete your own account. Query audit_logs. | 400 error returned. NO audit_log entry created for the attempt. | |
---
## 6. API Access Control
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 6.1 | Admin can query audit logs | Log in as admin. `GET /api/audit-logs`. | 200 response with logs array and pagination object | |
| 6.2 | Editor denied audit logs | Log in as editor. `GET /api/audit-logs`. | 403 response with `{"error":"Insufficient permissions"}` | |
| 6.3 | Viewer denied audit logs | Log in as viewer. `GET /api/audit-logs`. | 403 response | |
| 6.4 | Unauthenticated denied | Without a session cookie, `GET /api/audit-logs`. | 401 response | |
| 6.5 | Admin can get actions list | `GET /api/audit-logs/actions` as admin. | 200 response with array of distinct action strings | |
| 6.6 | Non-admin denied actions list | `GET /api/audit-logs/actions` as editor. | 403 response | |
---
## 7. API Filtering & Pagination
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 7.1 | Default pagination | `GET /api/audit-logs` (no params). | Returns up to 25 entries, page=1, correct total count and totalPages | |
| 7.2 | Custom page size | `GET /api/audit-logs?limit=5`. | Returns exactly 5 entries (if >= 5 exist). Pagination reflects limit=5. | |
| 7.3 | Page size capped at 100 | `GET /api/audit-logs?limit=999`. | Returns at most 100 entries per page | |
| 7.4 | Navigate to page 2 | `GET /api/audit-logs?page=2&limit=5`. | Returns entries 6-10 (offset=5). Entries differ from page 1. | |
| 7.5 | Filter by username | `GET /api/audit-logs?user=admin`. | Only entries where username contains "admin" | |
| 7.6 | Partial username match | `GET /api/audit-logs?user=adm`. | Matches "admin" (LIKE search) | |
| 7.7 | Filter by action | `GET /api/audit-logs?action=login`. | Only entries with action='login' (exact match) | |
| 7.8 | Filter by entity type | `GET /api/audit-logs?entityType=auth`. | Only auth-related entries | |
| 7.9 | Filter by date range | `GET /api/audit-logs?startDate=2025-01-01&endDate=2025-12-31`. | Only entries within the date range (inclusive) | |
| 7.10 | Combined filters | `GET /api/audit-logs?user=admin&action=login&entityType=auth`. | Only entries matching ALL filters simultaneously | |
| 7.11 | Empty result set | `GET /api/audit-logs?user=nonexistentuser`. | `{"logs":[],"pagination":{"page":1,"limit":25,"total":0,"totalPages":0}}` | |
| 7.12 | Ordering | Query audit logs without filters. | Entries ordered by created_at DESC (newest first) | |
---
## 8. Frontend - Audit Log Menu Access
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 8.1 | Admin sees Audit Log menu item | Log in as admin. Click user avatar to open dropdown menu. | "Audit Log" option visible with clock icon, positioned between "Manage Users" and "Sign Out" | |
| 8.2 | Editor does NOT see Audit Log | Log in as editor. Click user avatar. | No "Audit Log" or "Manage Users" options visible | |
| 8.3 | Viewer does NOT see Audit Log | Log in as viewer. Click user avatar. | No "Audit Log" or "Manage Users" options visible | |
| 8.4 | Clicking Audit Log opens modal | As admin, click "Audit Log" in the menu. | Modal overlay appears with audit log table. Menu dropdown closes. | |
| 8.5 | Menu closes on outside click | Open the user menu, then click outside the dropdown. | Dropdown closes | |
---
## 9. Frontend - Audit Log Modal
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 9.1 | Modal displays header | Open the Audit Log modal. | Title "Audit Log", subtitle "Track all user actions across the system", X close button visible | |
| 9.2 | Close button works | Click the X button on the modal. | Modal closes, returns to dashboard | |
| 9.3 | Loading state shown | Open the modal (observe briefly). | Spinner with "Loading audit logs..." appears before data loads | |
| 9.4 | Table columns correct | Open modal with data present. | Six columns visible: Time, User, Action, Entity, Details, IP Address | |
| 9.5 | Time formatting | Check the Time column. | Dates display in local format (e.g., "1/29/2026, 3:45:00 PM"), not raw ISO strings | |
| 9.6 | Action badges color-coded | View entries with different action types. | login=green, logout=gray, login_failed=red, cve_create=blue, cve_update_status=yellow, document_upload=purple, document_delete=red, user_create=blue, user_update=yellow, user_delete=red | |
| 9.7 | Entity column format | View entries with entity_type and entity_id. | Shows "cve CVE-2025-TEST-1" or "auth" (no ID for auth entries) | |
| 9.8 | Details column formatting | View an entry with JSON details. | Displays "key: value, key: value" format, not raw JSON | |
| 9.9 | Details truncation | View entry with long details. | Text truncated with ellipsis. Full text visible on hover (title attribute). | |
| 9.10 | IP address display | View entries. | IP addresses shown in monospace font. Null IPs show "-" | |
| 9.11 | Empty state | Apply filters that return no results. | "No audit log entries found." message displayed | |
| 9.12 | Error state | (Simulate: stop backend while modal is open, then apply filters.) | Error icon with error message displayed | |
---
## 10. Frontend - Filters
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 10.1 | Username filter | Type "admin" in username field, click Apply Filters. | Only entries with "admin" in username shown | |
| 10.2 | Action dropdown populated | Click the Action dropdown. | Lists all distinct actions present in the database (from `/api/audit-logs/actions`) | |
| 10.3 | Action filter | Select "login" from Action dropdown, click Apply. | Only login entries shown | |
| 10.4 | Entity type dropdown | Click the Entity Type dropdown. | Lists: auth, cve, document, user | |
| 10.5 | Entity type filter | Select "cve", click Apply. | Only CVE-related entries shown | |
| 10.6 | Date range filter | Set start date to today, set end date to today, click Apply. | Only entries from today shown | |
| 10.7 | Combined filters | Set username="admin", action="login", click Apply. | Only admin login entries shown | |
| 10.8 | Reset button | Set multiple filters, click Reset. | All filter fields cleared. (Note: table does not auto-refresh until Apply is clicked again.) | |
| 10.9 | Apply after reset | Click Reset, then click Apply Filters. | Full unfiltered results shown | |
| 10.10 | Filter resets to page 1 | Navigate to page 2, then apply a filter. | Results start from page 1 | |
---
## 11. Frontend - Pagination
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 11.1 | Pagination info displayed | Open modal with >25 entries. | Shows "Showing 1 - 25 of N entries" and "Page 1 of X" | |
| 11.2 | Next page button | Click the right chevron. | Page advances. Entry range updates. "Page 2 of X" shown. | |
| 11.3 | Previous page button | Navigate to page 2, then click left chevron. | Returns to page 1 | |
| 11.4 | First page - prev disabled | On page 1, check left chevron. | Button is disabled (grayed out, not clickable) | |
| 11.5 | Last page - next disabled | Navigate to the last page. | Right chevron is disabled | |
| 11.6 | Pagination hidden for few entries | Open modal with <= 25 total entries. | No pagination controls shown (totalPages <= 1) | |
| 11.7 | Entry count accuracy | Compare "Showing X - Y of Z" with actual table rows. | Row count matches Y - X + 1. Total Z matches database count. | |
---
## 12. Fire-and-Forget Behavior
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 12.1 | Audit failure does not break login | (Requires code-level test or corrupting audit_logs table temporarily.) Rename audit_logs table, attempt login. | Login succeeds. Console shows "Audit log error:" message. | |
| 12.2 | Audit failure does not break CVE create | With corrupted audit table, create a CVE. | CVE created successfully. Error logged to console only. | |
| 12.3 | Response not delayed by audit | Create a CVE and observe response time. | Response returns immediately; audit insert is non-blocking. | |
---
## 13. Data Integrity
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 13.1 | Audit survives user deletion | Create user, perform actions, delete user. Query audit_logs for that username. | Audit entries remain with the username preserved (denormalized). No foreign key cascade. | |
| 13.2 | Details stored as valid JSON | Query `SELECT details FROM audit_logs WHERE details IS NOT NULL LIMIT 5`. Parse each. | All non-null details values are valid JSON strings | |
| 13.3 | IP address captured | Query entries created via browser. | ip_address field contains the client IP (e.g., `::1` for localhost or `127.0.0.1`) | |
| 13.4 | Timestamps auto-populated | Query entries without explicitly setting created_at. | All rows have a created_at value, not NULL | |
| 13.5 | Null entity_id for auth actions | Query `SELECT * FROM audit_logs WHERE entity_type='auth'`. | entity_id is NULL for login/logout/login_failed entries | |
---
## 14. End-to-End Workflow
| # | Test Case | Steps | Expected Result | Pass/Fail |
|---|-----------|-------|-----------------|-----------|
| 14.1 | Full user lifecycle | 1. Admin logs in 2. Creates user "testuser2" 3. testuser2 logs in 4. testuser2 creates a CVE 5. Admin updates testuser2's role 6. Admin deletes testuser2 7. Open Audit Log and review | All 6 actions visible in the audit log in reverse chronological order. Each entry has correct user, action, entity, and details. | |
| 14.2 | Filter down to one user's actions | Perform test 14.1, then filter by username="testuser2". | Only testuser2's own actions shown (login, cve_create). Admin actions on testuser2 show admin as the actor. | |
| 14.3 | Security audit trail | Attempt 3 failed logins with wrong password, then succeed. Open Audit Log, filter action="login_failed". | All 3 failed attempts visible with timestamps and IP addresses. Useful for detecting brute force. | |
---
## Test Summary
| Section | Tests | Description |
|---------|-------|-------------|
| 1. Database & Schema | 5 | Table creation, indexes, migration idempotency |
| 2. Auth Logging | 6 | Login success/failure variants, logout |
| 3. CVE Logging | 4 | Create, status update, bug fix verification |
| 4. Document Logging | 3 | Upload, delete, metadata capture |
| 5. User Mgmt Logging | 7 | Create, update, delete, edge cases |
| 6. API Access Control | 6 | Admin-only enforcement on all endpoints |
| 7. API Filtering | 12 | Pagination, filters, combined queries |
| 8. Menu Access | 5 | Role-based UI visibility |
| 9. Modal Display | 12 | Table rendering, formatting, states |
| 10. Frontend Filters | 10 | Filter UI interaction and behavior |
| 11. Pagination UI | 7 | Navigation, boundary conditions |
| 12. Fire-and-Forget | 3 | Non-blocking audit behavior |
| 13. Data Integrity | 5 | Denormalization, JSON, timestamps |
| 14. End-to-End | 3 | Full workflow validation |
| **Total** | **88** | |

View File

@@ -2,3 +2,7 @@
PORT=3001
API_HOST=localhost
CORS_ORIGINS=http://localhost:3000
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
# Request one at https://nvd.nist.gov/developers/request-an-api-key
NVD_API_KEY=

5
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# One-time migration scripts
backend/fix_multivendor_constraint.js
backend/migrate_multivendor.js
backend/add_vendor_to_documents.js

View File

@@ -0,0 +1,21 @@
// Audit Log Helper
// Fire-and-forget insert - never blocks the response
function logAudit(db, { userId, username, action, entityType, entityId, details, ipAddress }) {
const detailsStr = details && typeof details === 'object'
? JSON.stringify(details)
: details || null;
db.run(
`INSERT INTO audit_logs (user_id, username, action, entity_type, entity_id, details, ip_address)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[userId || null, username || 'unknown', action, entityType, entityId || null, detailsStr, ipAddress || null],
(err) => {
if (err) {
console.error('Audit log error:', err.message);
}
}
);
}
module.exports = logAudit;

View File

@@ -0,0 +1,70 @@
// Authentication Middleware
// Require authenticated user
function requireAuth(db) {
return async (req, res, next) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const session = await new Promise((resolve, reject) => {
db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
[sessionId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!session) {
return res.status(401).json({ error: 'Session expired or invalid' });
}
if (!session.is_active) {
return res.status(401).json({ error: 'Account is disabled' });
}
// Attach user to request
req.user = {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
};
next();
} catch (err) {
console.error('Auth middleware error:', err);
return res.status(500).json({ error: 'Authentication error' });
}
};
}
// Require specific role(s)
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: allowedRoles,
current: req.user.role
});
}
next();
};
}
module.exports = { requireAuth, requireRole };

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
// Migration script: Add audit_logs table
// Run: node migrate-audit-log.js
const sqlite3 = require('sqlite3').verbose();
const fs = require('fs');
const DB_FILE = './cve_database.db';
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
function run(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve(this);
});
});
}
function get(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
async function migrate() {
console.log('╔════════════════════════════════════════════════════════╗');
console.log('║ CVE Database Migration: Add Audit Logs ║');
console.log('╚════════════════════════════════════════════════════════╝\n');
if (!fs.existsSync(DB_FILE)) {
console.log('❌ Database not found. Run setup.js for fresh install.');
process.exit(1);
}
// Backup database
console.log('📦 Creating backup...');
fs.copyFileSync(DB_FILE, BACKUP_FILE);
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
const db = new sqlite3.Database(DB_FILE);
try {
// Check if table already exists
const exists = await get(db,
"SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'"
);
if (exists) {
console.log('⏭️ audit_logs table already exists, nothing to do.');
} else {
console.log('1⃣ Creating audit_logs table...');
await run(db, `
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100),
details TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.log(' ✓ Table created');
console.log('2⃣ Creating indexes...');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at)');
console.log(' ✓ Indexes created');
}
console.log('\n╔════════════════════════════════════════════════════════╗');
console.log('║ MIGRATION COMPLETE! ║');
console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📋 Summary:');
console.log(' ✓ audit_logs table ready');
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
console.log('\n🚀 Restart your server to apply changes.\n');
} catch (error) {
console.error('\n❌ Migration failed:', error.message);
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
process.exit(1);
} finally {
db.close();
}
}
migrate();

289
backend/migrate-to-1.1.js Executable file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env node
// Migration script: v1.0.0 -> v1.1.0
// Adds: users, sessions tables, multi-vendor support, vendor column in documents
// Run: node migrate-to-1.1.js
const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
const DB_FILE = './cve_database.db';
const BACKUP_FILE = `./cve_database_backup_${Date.now()}.db`;
async function migrate() {
console.log('╔════════════════════════════════════════════════════════╗');
console.log('║ CVE Database Migration: v1.0.0 → v1.1.0 ║');
console.log('╚════════════════════════════════════════════════════════╝\n');
// Check if database exists
if (!fs.existsSync(DB_FILE)) {
console.log('❌ Database not found. Run setup.js for fresh install.');
process.exit(1);
}
// Backup database
console.log('📦 Creating backup...');
fs.copyFileSync(DB_FILE, BACKUP_FILE);
console.log(` ✓ Backup saved to: ${BACKUP_FILE}\n`);
const db = new sqlite3.Database(DB_FILE);
try {
// Run migrations in sequence
await addUsersTable(db);
await addSessionsTable(db);
await addVendorToDocuments(db);
await updateCvesConstraint(db);
await createDefaultAdmin(db);
await updateView(db);
console.log('\n╔════════════════════════════════════════════════════════╗');
console.log('║ MIGRATION COMPLETE! ║');
console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📋 Summary:');
console.log(' ✓ Users table added');
console.log(' ✓ Sessions table added');
console.log(' ✓ Vendor column added to documents');
console.log(' ✓ Multi-vendor constraint applied to cves');
console.log(' ✓ Default admin user created (admin/admin123)');
console.log(`\n💾 Backup saved: ${BACKUP_FILE}`);
console.log('\n🚀 Restart your server to apply changes.\n');
} catch (error) {
console.error('\n❌ Migration failed:', error.message);
console.log(`\n🔄 To restore from backup: cp ${BACKUP_FILE} ${DB_FILE}`);
process.exit(1);
} finally {
db.close();
}
}
function run(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve(this);
});
});
}
function get(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
function all(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
}
async function addUsersTable(db) {
console.log('1⃣ Adding users table...');
const exists = await get(db,
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
);
if (exists) {
console.log(' ⏭️ Users table already exists, skipping');
return;
}
await run(db, `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
CHECK (role IN ('admin', 'editor', 'viewer'))
)
`);
await run(db, 'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)');
console.log(' ✓ Users table created');
}
async function addSessionsTable(db) {
console.log('2⃣ Adding sessions table...');
const exists = await get(db,
"SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'"
);
if (exists) {
console.log(' ⏭️ Sessions table already exists, skipping');
return;
}
await run(db, `
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)');
console.log(' ✓ Sessions table created');
}
async function addVendorToDocuments(db) {
console.log('3⃣ Adding vendor column to documents...');
// Check if vendor column exists
const columns = await all(db, "PRAGMA table_info(documents)");
const hasVendor = columns.some(col => col.name === 'vendor');
if (hasVendor) {
console.log(' ⏭️ Vendor column already exists, skipping');
return;
}
// Add vendor column
await run(db, "ALTER TABLE documents ADD COLUMN vendor VARCHAR(100)");
// Populate vendor from the cves table based on cve_id
await run(db, `
UPDATE documents
SET vendor = (
SELECT c.vendor
FROM cves c
WHERE c.cve_id = documents.cve_id
LIMIT 1
)
WHERE vendor IS NULL
`);
// Set default for any remaining nulls
await run(db, "UPDATE documents SET vendor = 'Unknown' WHERE vendor IS NULL");
await run(db, 'CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor)');
console.log(' ✓ Vendor column added and populated');
}
async function updateCvesConstraint(db) {
console.log('4⃣ Updating CVEs table for multi-vendor support...');
// Check current schema
const tableInfo = await get(db,
"SELECT sql FROM sqlite_master WHERE type='table' AND name='cves'"
);
if (tableInfo.sql.includes('UNIQUE(cve_id, vendor)')) {
console.log(' ⏭️ Multi-vendor constraint already exists, skipping');
return;
}
// SQLite doesn't support ALTER CONSTRAINT, so we need to rebuild the table
console.log(' 📋 Rebuilding table with new constraint...');
// Create new table with correct schema
await run(db, `
CREATE TABLE cves_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id VARCHAR(20) NOT NULL,
vendor VARCHAR(100) NOT NULL,
severity VARCHAR(20) NOT NULL,
description TEXT,
published_date DATE,
status VARCHAR(50) DEFAULT 'Open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cve_id, vendor)
)
`);
// Copy data
await run(db, `
INSERT INTO cves_new (id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at)
SELECT id, cve_id, vendor, severity, description, published_date, status, created_at, updated_at
FROM cves
`);
// Drop old table
await run(db, 'DROP TABLE cves');
// Rename new table
await run(db, 'ALTER TABLE cves_new RENAME TO cves');
// Recreate indexes
await run(db, 'CREATE INDEX IF NOT EXISTS idx_cve_id ON cves(cve_id)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_vendor ON cves(vendor)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_severity ON cves(severity)');
await run(db, 'CREATE INDEX IF NOT EXISTS idx_status ON cves(status)');
console.log(' ✓ Multi-vendor constraint applied');
}
async function createDefaultAdmin(db) {
console.log('5⃣ Creating default admin user...');
const exists = await get(db, "SELECT id FROM users WHERE username = 'admin'");
if (exists) {
console.log(' ⏭️ Admin user already exists, skipping');
return;
}
const passwordHash = await bcrypt.hash('admin123', 10);
await run(db, `
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?)
`, ['admin', 'admin@localhost', passwordHash, 'admin', 1]);
console.log(' ✓ Admin user created (admin/admin123)');
}
async function updateView(db) {
console.log('6⃣ Updating document status view...');
// Drop old view if exists
await run(db, 'DROP VIEW IF EXISTS cve_document_status');
// Create updated view with multi-vendor support
await run(db, `
CREATE VIEW cve_document_status AS
SELECT
c.id as record_id,
c.cve_id,
c.vendor,
c.severity,
c.status,
COUNT(DISTINCT d.id) as total_documents,
COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) as advisory_count,
COUNT(DISTINCT CASE WHEN d.type = 'email' THEN d.id END) as email_count,
COUNT(DISTINCT CASE WHEN d.type = 'screenshot' THEN d.id END) as screenshot_count,
CASE
WHEN COUNT(DISTINCT CASE WHEN d.type = 'advisory' THEN d.id END) > 0
THEN 'Complete'
ELSE 'Missing Required Docs'
END as compliance_status
FROM cves c
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
GROUP BY c.id, c.cve_id, c.vendor, c.severity, c.status
`);
console.log(' ✓ View updated');
}
// Run migration
migrate();

114
backend/routes/auditLog.js Normal file
View File

@@ -0,0 +1,114 @@
// Audit Log Routes (Admin only)
const express = require('express');
function createAuditLogRouter(db, requireAuth, requireRole) {
const router = express.Router();
// All routes require admin role
router.use(requireAuth(db), requireRole('admin'));
// Get paginated audit logs with filters
router.get('/', async (req, res) => {
const {
page = 1,
limit = 25,
user,
action,
entityType,
startDate,
endDate
} = req.query;
const offset = (Math.max(1, parseInt(page)) - 1) * parseInt(limit);
const pageSize = Math.min(100, Math.max(1, parseInt(limit)));
let where = [];
let params = [];
if (user) {
where.push('username LIKE ?');
params.push(`%${user}%`);
}
if (action) {
where.push('action = ?');
params.push(action);
}
if (entityType) {
where.push('entity_type = ?');
params.push(entityType);
}
if (startDate) {
where.push('created_at >= ?');
params.push(startDate);
}
if (endDate) {
where.push('created_at <= ?');
params.push(endDate + ' 23:59:59');
}
const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
try {
// Get total count
const countRow = await new Promise((resolve, reject) => {
db.get(
`SELECT COUNT(*) as total FROM audit_logs ${whereClause}`,
params,
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
// Get paginated results
const rows = await new Promise((resolve, reject) => {
db.all(
`SELECT * FROM audit_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...params, pageSize, offset],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json({
logs: rows,
pagination: {
page: parseInt(page),
limit: pageSize,
total: countRow.total,
totalPages: Math.ceil(countRow.total / pageSize)
}
});
} catch (err) {
console.error('Audit log query error:', err);
res.status(500).json({ error: 'Failed to fetch audit logs' });
}
});
// Get distinct action types for filter dropdown
router.get('/actions', async (req, res) => {
try {
const rows = await new Promise((resolve, reject) => {
db.all(
'SELECT DISTINCT action FROM audit_logs ORDER BY action',
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json(rows.map(r => r.action));
} catch (err) {
console.error('Audit log actions error:', err);
res.status(500).json({ error: 'Failed to fetch actions' });
}
});
return router;
}
module.exports = createAuditLogRouter;

249
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,249 @@
// Authentication Routes
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
function createAuthRouter(db, logAudit) {
const router = express.Router();
// Login
router.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
try {
// Find user
const user = await new Promise((resolve, reject) => {
db.get(
'SELECT * FROM users WHERE username = ?',
[username],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!user) {
logAudit(db, {
userId: null,
username: username,
action: 'login_failed',
entityType: 'auth',
entityId: null,
details: { reason: 'user_not_found' },
ipAddress: req.ip
});
return res.status(401).json({ error: 'Invalid username or password' });
}
if (!user.is_active) {
logAudit(db, {
userId: user.id,
username: username,
action: 'login_failed',
entityType: 'auth',
entityId: null,
details: { reason: 'account_disabled' },
ipAddress: req.ip
});
return res.status(401).json({ error: 'Account is disabled' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
logAudit(db, {
userId: user.id,
username: username,
action: 'login_failed',
entityType: 'auth',
entityId: null,
details: { reason: 'invalid_password' },
ipAddress: req.ip
});
return res.status(401).json({ error: 'Invalid username or password' });
}
// Generate session ID
const sessionId = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
// Create session
await new Promise((resolve, reject) => {
db.run(
'INSERT INTO sessions (session_id, user_id, expires_at) VALUES (?, ?, ?)',
[sessionId, user.id, expiresAt.toISOString()],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Update last login
await new Promise((resolve, reject) => {
db.run(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
[user.id],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
// Set cookie
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
logAudit(db, {
userId: user.id,
username: user.username,
action: 'login',
entityType: 'auth',
entityId: null,
details: { role: user.role },
ipAddress: req.ip
});
res.json({
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
// Logout
router.post('/logout', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (sessionId) {
// Look up user before deleting session
const session = await new Promise((resolve) => {
db.get(
`SELECT u.id as user_id, u.username FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = ?`,
[sessionId],
(err, row) => resolve(row || null)
);
});
// Delete session from database
await new Promise((resolve) => {
db.run(
'DELETE FROM sessions WHERE session_id = ?',
[sessionId],
() => resolve()
);
});
if (session) {
logAudit(db, {
userId: session.user_id,
username: session.username,
action: 'logout',
entityType: 'auth',
entityId: null,
details: null,
ipAddress: req.ip
});
}
}
// Clear cookie
res.clearCookie('session_id');
res.json({ message: 'Logged out successfully' });
});
// Get current user
router.get('/me', async (req, res) => {
const sessionId = req.cookies?.session_id;
if (!sessionId) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const session = await new Promise((resolve, reject) => {
db.get(
`SELECT s.*, u.id as user_id, u.username, u.email, u.role, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > datetime('now')`,
[sessionId],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!session) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Session expired' });
}
if (!session.is_active) {
res.clearCookie('session_id');
return res.status(401).json({ error: 'Account is disabled' });
}
res.json({
user: {
id: session.user_id,
username: session.username,
email: session.email,
role: session.role
}
});
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to get user' });
}
});
// Clean up expired sessions (admin only)
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 {
await new Promise((resolve, reject) => {
db.run(
"DELETE FROM sessions WHERE expires_at < datetime('now')",
(err) => {
if (err) reject(err);
else resolve();
}
);
});
res.json({ message: 'Expired sessions cleaned up' });
} catch (err) {
console.error('Session cleanup error:', err);
res.status(500).json({ error: 'Cleanup failed' });
}
});
return router;
}
module.exports = createAuthRouter;

View 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;

262
backend/routes/users.js Normal file
View File

@@ -0,0 +1,262 @@
// User Management Routes (Admin only)
const express = require('express');
const bcrypt = require('bcryptjs');
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
const router = express.Router();
// All routes require admin role
router.use(requireAuth(db), requireRole('admin'));
// Get all users
router.get('/', async (req, res) => {
try {
const users = await new Promise((resolve, reject) => {
db.all(
`SELECT id, username, email, role, is_active, created_at, last_login
FROM users ORDER BY created_at DESC`,
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.json(users);
} catch (err) {
console.error('Get users error:', err);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// Get single user
router.get('/:id', async (req, res) => {
try {
const user = await new Promise((resolve, reject) => {
db.get(
`SELECT id, username, email, role, is_active, created_at, last_login
FROM users WHERE id = ?`,
[req.params.id],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to fetch user' });
}
});
// Create new user
router.post('/', async (req, res) => {
const { username, email, password, role } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role. Must be admin, editor, or viewer' });
}
try {
const passwordHash = await bcrypt.hash(password, 10);
const result = await new Promise((resolve, reject) => {
db.run(
`INSERT INTO users (username, email, password_hash, role)
VALUES (?, ?, ?, ?)`,
[username, email, passwordHash, role || 'viewer'],
function(err) {
if (err) reject(err);
else resolve({ id: this.lastID });
}
);
});
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_create',
entityType: 'user',
entityId: String(result.id),
details: { created_username: username, role: role || 'viewer' },
ipAddress: req.ip
});
res.status(201).json({
message: 'User created successfully',
user: {
id: result.id,
username,
email,
role: role || 'viewer'
}
});
} catch (err) {
console.error('Create user error:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username or email already exists' });
}
res.status(500).json({ error: 'Failed to create user' });
}
});
// Update user
router.patch('/:id', async (req, res) => {
const { username, email, password, role, is_active } = req.body;
const userId = req.params.id;
// Prevent self-demotion from admin
if (userId == req.user.id && role && role !== 'admin') {
return res.status(400).json({ error: 'Cannot remove your own admin role' });
}
// Prevent self-deactivation
if (userId == req.user.id && is_active === false) {
return res.status(400).json({ error: 'Cannot deactivate your own account' });
}
try {
const updates = [];
const values = [];
if (username) {
updates.push('username = ?');
values.push(username);
}
if (email) {
updates.push('email = ?');
values.push(email);
}
if (password) {
const passwordHash = await bcrypt.hash(password, 10);
updates.push('password_hash = ?');
values.push(passwordHash);
}
if (role) {
if (!['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
updates.push('role = ?');
values.push(role);
}
if (typeof is_active === 'boolean') {
updates.push('is_active = ?');
values.push(is_active ? 1 : 0);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
values.push(userId);
await new Promise((resolve, reject) => {
db.run(
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
values,
function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
}
);
});
const updatedFields = {};
if (username) updatedFields.username = username;
if (email) updatedFields.email = email;
if (role) updatedFields.role = role;
if (typeof is_active === 'boolean') updatedFields.is_active = is_active;
if (password) updatedFields.password_changed = true;
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_update',
entityType: 'user',
entityId: String(userId),
details: updatedFields,
ipAddress: req.ip
});
// If user was deactivated, delete their sessions
if (is_active === false) {
await new Promise((resolve) => {
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
}
res.json({ message: 'User updated successfully' });
} catch (err) {
console.error('Update user error:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username or email already exists' });
}
res.status(500).json({ error: 'Failed to update user' });
}
});
// Delete user
router.delete('/:id', async (req, res) => {
const userId = req.params.id;
// Prevent self-deletion
if (userId == req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
try {
// Look up the user before deleting
const targetUser = await new Promise((resolve, reject) => {
db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
// Delete sessions first (foreign key)
await new Promise((resolve) => {
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
});
// Delete user
const result = await new Promise((resolve, reject) => {
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
if (result.changes === 0) {
return res.status(404).json({ error: 'User not found' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'user_delete',
entityType: 'user',
entityId: String(userId),
details: { deleted_username: targetUser ? targetUser.username : 'unknown' },
ipAddress: req.ip
});
res.json({ message: 'User deleted successfully' });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Failed to delete user' });
}
});
return router;
}
module.exports = createUsersRouter;

View File

@@ -1,5 +1,5 @@
// CVE Management Backend API
// Install: npm install express sqlite3 multer cors dotenv
// Install: npm install express sqlite3 multer cors dotenv bcryptjs cookie-parser
require('dotenv').config();
@@ -7,29 +7,110 @@ const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const multer = require('multer');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const path = require('path');
const fs = require('fs');
// Auth imports
const { requireAuth, requireRole } = require('./middleware/auth');
const createAuthRouter = require('./routes/auth');
const createUsersRouter = require('./routes/users');
const createAuditLogRouter = require('./routes/auditLog');
const logAudit = require('./helpers/auditLog');
const createNvdLookupRouter = require('./routes/nvdLookup');
const app = express();
const PORT = process.env.PORT || 3001;
const API_HOST = process.env.API_HOST || 'localhost';
const SESSION_SECRET = process.env.SESSION_SECRET || 'default-secret-change-me';
const CORS_ORIGINS = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['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
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
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
app.use(cors({
origin: CORS_ORIGINS,
credentials: true
}));
app.use(express.json());
app.use('/uploads', express.static('uploads'));
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use('/uploads', express.static('uploads', {
dotfiles: 'deny',
index: false
}));
// Database connection
const db = new sqlite3.Database('./cve_database.db', (err) => {
@@ -37,6 +118,18 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
else console.log('Connected to CVE database');
});
// Auth routes (public)
app.use('/api/auth', createAuthRouter(db, logAudit));
// User management routes (admin only)
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole, logAudit));
// Audit log routes (admin only)
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireRole));
// NVD lookup routes (authenticated users)
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
// Simple storage - upload to temp directory first
const storage = multer.diskStorage({
destination: (req, file, cb) => {
@@ -48,31 +141,41 @@ const storage = multer.diskStorage({
},
filename: (req, file, cb) => {
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}`);
}
});
const upload = multer({
// 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({
storage: storage,
fileFilter: fileFilter,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
});
// ========== CVE ENDPOINTS ==========
// Get all CVEs with optional filters
app.get('/api/cves', (req, res) => {
// Get all CVEs with optional filters (authenticated users)
app.get('/api/cves', requireAuth(db), (req, res) => {
const { search, vendor, severity, status } = req.query;
let query = `
SELECT c.*,
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
SELECT c.*, COUNT(d.id) as document_count
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
`;
@@ -100,20 +203,27 @@ app.get('/api/cves', (req, res) => {
db.all(query, params, (err, rows) => {
if (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);
});
});
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR
app.get('/api/cves/check/:cveId', (req, res) => {
// Get distinct CVE IDs for NVD sync (authenticated users)
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
if (err) { 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)
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
const { cveId } = req.params;
const query = `
SELECT c.*,
SELECT c.*,
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 = 'screenshot' THEN 1 END) as has_screenshot
FROM cves c
@@ -121,18 +231,18 @@ app.get('/api/cves/check/:cveId', (req, res) => {
WHERE c.cve_id = ?
GROUP BY c.id
`;
db.all(query, [cveId], (err, rows) => {
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) {
return res.json({
exists: false,
message: 'CVE not found - not yet addressed'
return res.json({
exists: false,
message: 'CVE not found - not yet addressed'
});
}
// Return all vendor entries for this CVE
res.json({
exists: true,
@@ -141,20 +251,18 @@ app.get('/api/cves/check/:cveId', (req, res) => {
severity: row.severity,
status: row.status,
total_documents: row.total_documents,
compliance: {
advisory: row.has_advisory > 0,
doc_types: {
email: row.has_email > 0,
screenshot: row.has_screenshot > 0
}
})),
addressed: true,
has_required_docs: rows.some(row => row.has_advisory > 0)
addressed: true
});
});
});
// NEW ENDPOINT: Get all vendors for a specific CVE
app.get('/api/cves/:cveId/vendors', (req, res) => {
// NEW ENDPOINT: Get all vendors for a specific CVE (authenticated users)
app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
const { cveId } = req.params;
const query = `
@@ -166,70 +274,410 @@ app.get('/api/cves/:cveId/vendors', (req, res) => {
db.all(query, [cveId], (err, rows) => {
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);
});
});
// Create new CVE entry - ALLOW MULTIPLE VENDORS
app.post('/api/cves', (req, res) => {
console.log('=== ADD CVE REQUEST ===');
console.log('Body:', req.body);
console.log('=======================');
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
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 = `
INSERT INTO cves (cve_id, vendor, severity, description, published_date)
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) {
if (err) {
console.error('DATABASE ERROR:', err); // Make sure this is here
// ... rest of error handling
// Check if it's a duplicate CVE_ID + Vendor combination
console.error('DATABASE ERROR:', err);
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
return res.status(409).json({
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.' });
}
res.json({
id: this.lastID,
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'cve_create',
entityType: 'cve',
entityId: cve_id,
details: { vendor, severity },
ipAddress: req.ip
});
res.json({
id: this.lastID,
cve_id,
message: `CVE created successfully for vendor: ${vendor}`
message: `CVE created successfully for vendor: ${vendor}`
});
});
});
// Update CVE status
app.patch('/api/cves/:cveId/status', (req, res) => {
// Update CVE status (editor or admin)
app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'admin'), (req, res) => {
const { cveId } = req.params;
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 = ?`;
db.run(query, [
vendor,status, cveId], function(err) {
db.run(query, [status, cveId], function(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, {
userId: req.user.id,
username: req.user.username,
action: 'cve_update_status',
entityType: 'cve',
entityId: cveId,
details: { status },
ipAddress: req.ip
});
res.json({ message: 'Status updated successfully', changes: this.changes });
});
});
// 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 ==========
// Get documents for a CVE - FILTER BY VENDOR
app.get('/api/cves/:cveId/documents', (req, res) => {
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
const { cveId } = req.params;
const { vendor } = req.query; // NEW: Optional vendor filter
@@ -245,26 +693,27 @@ app.get('/api/cves/:cveId/documents', (req, res) => {
db.all(query, params, (err, rows) => {
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);
});
});
// Upload document - ADD ERROR HANDLING FOR MULTER
app.post('/api/cves/:cveId/documents', (req, res, next) => {
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'admin'), (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) {
console.error('MULTER ERROR:', err);
return res.status(500).json({ error: 'File upload failed: ' + err.message });
console.error('Upload error:', 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 { type, notes, vendor } = req.body;
const file = req.file;
@@ -275,18 +724,41 @@ app.post('/api/cves/:cveId/documents', (req, res, next) => {
}
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' });
}
// 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
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)) {
fs.mkdirSync(finalDir, { recursive: true });
}
const finalPath = path.join(finalDir, file.filename);
// Move file from temp to final location
fs.renameSync(file.path, finalPath);
@@ -308,43 +780,61 @@ app.post('/api/cves/:cveId/documents', (req, res, next) => {
notes
], function(err) {
if (err) {
console.error('DATABASE ERROR:', err);
console.error('Document insert error:', err);
// If database insert fails, delete the file
if (fs.existsSync(finalPath)) {
fs.unlinkSync(finalPath);
}
return res.status(500).json({ error: err.message });
return res.status(500).json({ error: 'Internal server error.' });
}
logAudit(db, {
userId: req.user.id,
username: req.user.username,
action: 'document_upload',
entityType: 'document',
entityId: cveId,
details: { vendor, type, filename: file.originalname },
ipAddress: req.ip
});
res.json({
id: this.lastID,
message: 'Document uploaded successfully',
file: {
name: file.originalname,
path: finalPath,
size: fileSizeKB
}
});
});
});
});
// Delete document
app.delete('/api/documents/:id', (req, res) => {
// Delete document (admin only)
app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, res) => {
const { id } = req.params;
// First get the file path to delete the actual file
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
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);
}
db.run('DELETE FROM documents WHERE id = ?', [id], function(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, {
userId: req.user.id,
username: req.user.username,
action: 'document_delete',
entityType: 'document',
entityId: id,
details: { file_path: row ? row.file_path : null },
ipAddress: req.ip
});
res.json({ message: 'Document deleted successfully' });
});
});
@@ -352,20 +842,20 @@ app.delete('/api/documents/:id', (req, res) => {
// ========== UTILITY ENDPOINTS ==========
// Get all vendors
app.get('/api/vendors', (req, res) => {
// Get all vendors (authenticated users)
app.get('/api/vendors', requireAuth(db), (req, res) => {
const query = `SELECT DISTINCT vendor FROM cves ORDER BY vendor`;
db.all(query, [], (err, rows) => {
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));
});
});
// Get statistics
app.get('/api/stats', (req, res) => {
// Get statistics (authenticated users)
app.get('/api/stats', requireAuth(db), (req, res) => {
const query = `
SELECT
COUNT(DISTINCT c.id) as total_cves,
@@ -380,7 +870,7 @@ app.get('/api/stats', (req, res) => {
db.get(query, [], (err, row) => {
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);
});

88
backend/setup-env.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
# Setup script for CVE Dashboard environment configuration
# Creates .env files from .env.example templates with proper IP configuration
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
echo "CVE Dashboard Environment Setup"
echo "================================"
echo ""
# Detect current IP address
DETECTED_IP=$(hostname -I | awk '{print $1}')
# Prompt for IP address
read -p "Enter server IP address [$DETECTED_IP]: " INPUT_IP
SERVER_IP="${INPUT_IP:-$DETECTED_IP}"
if [ -z "$SERVER_IP" ]; then
echo "ERROR: No IP address provided and auto-detection failed."
exit 1
fi
echo ""
echo "Using IP: $SERVER_IP"
echo ""
# Setup backend .env
BACKEND_ENV="$PROJECT_ROOT/backend/.env"
if [ -f "$BACKEND_ENV" ]; then
read -p "backend/.env exists. Overwrite? [y/N]: " OVERWRITE
if [[ ! "$OVERWRITE" =~ ^[Yy]$ ]]; then
echo "Skipping backend/.env"
else
cat > "$BACKEND_ENV" << ENVFILE
# Backend Configuration
PORT=3001
API_HOST=$SERVER_IP
CORS_ORIGINS=http://$SERVER_IP:3000
ENVFILE
echo "Created backend/.env"
fi
else
cat > "$BACKEND_ENV" << ENVFILE
# Backend Configuration
PORT=3001
API_HOST=$SERVER_IP
CORS_ORIGINS=http://$SERVER_IP:3000
ENVFILE
echo "Created backend/.env"
fi
# Setup frontend .env
FRONTEND_ENV="$PROJECT_ROOT/frontend/.env"
if [ -f "$FRONTEND_ENV" ]; then
read -p "frontend/.env exists. Overwrite? [y/N]: " OVERWRITE
if [[ ! "$OVERWRITE" =~ ^[Yy]$ ]]; then
echo "Skipping frontend/.env"
else
cat > "$FRONTEND_ENV" << ENVFILE
# Frontend Configuration
# API_BASE should include the /api path
REACT_APP_API_BASE=http://$SERVER_IP:3001/api
# API_HOST is used for direct file URLs (no /api)
REACT_APP_API_HOST=http://$SERVER_IP:3001
ENVFILE
echo "Created frontend/.env"
fi
else
cat > "$FRONTEND_ENV" << ENVFILE
# Frontend Configuration
# API_BASE should include the /api path
REACT_APP_API_BASE=http://$SERVER_IP:3001/api
# API_HOST is used for direct file URLs (no /api)
REACT_APP_API_HOST=http://$SERVER_IP:3001
ENVFILE
echo "Created frontend/.env"
fi
echo ""
echo "Setup complete!"
echo ""
echo "Next steps:"
echo " 1. If servers are running, restart them to apply changes"
echo " 2. Run: ./start-servers.sh"
echo ""

92
backend/setup.js Normal file → Executable file
View File

@@ -2,6 +2,7 @@
// This creates a fresh database with multi-vendor support built-in
const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
@@ -59,6 +60,52 @@ function initializeDatabase() {
CREATE INDEX IF NOT EXISTS idx_doc_vendor ON documents(vendor);
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(type);
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
CHECK (role IN ('admin', 'editor', 'viewer'))
);
-- Sessions table for session management
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id VARCHAR(255) UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- Audit log table for tracking user actions
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100),
details TEXT,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
@@ -109,6 +156,42 @@ function createUploadsDirectory() {
}
}
// Create default admin user
async function createDefaultAdmin(db) {
return new Promise((resolve, reject) => {
// Check if admin already exists
db.get('SELECT id FROM users WHERE username = ?', ['admin'], async (err, row) => {
if (err) {
reject(err);
return;
}
if (row) {
console.log('✓ Default admin user already exists');
resolve();
return;
}
// Create admin user with password 'admin123'
const passwordHash = await bcrypt.hash('admin123', 10);
db.run(
`INSERT INTO users (username, email, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?)`,
['admin', 'admin@localhost', passwordHash, 'admin', 1],
(err) => {
if (err) {
reject(err);
} else {
console.log('✓ Created default admin user (admin/admin123)');
resolve();
}
}
);
});
});
}
// Add sample CVE data (optional - for testing)
async function addSampleData(db) {
console.log('\n📝 Adding sample CVE data for testing...');
@@ -179,12 +262,14 @@ function displaySummary() {
console.log('╚════════════════════════════════════════════════════════╝');
console.log('\n📊 What was created:');
console.log(' ✓ SQLite database (cve_database.db)');
console.log(' ✓ Tables: cves, documents, required_documents');
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions, audit_logs');
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
console.log(' ✓ Vendor column in documents table');
console.log(' ✓ User authentication with session-based auth');
console.log(' ✓ Indexes for fast queries');
console.log(' ✓ Document compliance view');
console.log(' ✓ Uploads directory for file storage');
console.log(' ✓ Default admin user (admin/admin123)');
console.log('\n📁 File structure will be:');
console.log(' uploads/');
console.log(' └── CVE-XXXX-XXXX/');
@@ -219,7 +304,10 @@ async function main() {
// Initialize database
const db = await initializeDatabase();
// Create default admin user
await createDefaultAdmin(db);
// Add sample data
await addSampleData(db);

0
frontend/cve_database.db Normal file
View File

View File

@@ -7,7 +7,7 @@
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
content="Steam CVE Dashboard - Vulnerability tracking and documentation"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
@@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>CVE Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "SCD",
"name": "Steam CVE Dashboard",
"icons": [
{
"src": "favicon.ico",

View File

@@ -1,5 +1,11 @@
import React, { useState, useEffect } from 'react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus } from 'lucide-react';
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown } from 'lucide-react';
import { useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import UserMenu from './components/UserMenu';
import UserManagement from './components/UserManagement';
import AuditLog from './components/AuditLog';
import NvdSyncModal from './components/NvdSyncModal';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
@@ -7,6 +13,7 @@ const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
const severityLevels = ['All Severities', 'Critical', 'High', 'Medium', 'Low'];
export default function App() {
const { isAuthenticated, loading: authLoading, canWrite, isAdmin } = useAuth();
const [searchQuery, setSearchQuery] = useState('');
const [selectedVendor, setSelectedVendor] = useState('All Vendors');
const [selectedSeverity, setSelectedSeverity] = useState('All Severities');
@@ -21,6 +28,9 @@ export default function App() {
const [quickCheckCVE, setQuickCheckCVE] = useState('');
const [quickCheckResult, setQuickCheckResult] = useState(null);
const [showAddCVE, setShowAddCVE] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showNvdSync, setShowNvdSync] = useState(false);
const [newCVE, setNewCVE] = useState({
cve_id: '',
vendor: '',
@@ -29,19 +39,55 @@ export default function App() {
published_date: new Date().toISOString().split('T')[0]
});
const [uploadingFile, setUploadingFile] = useState(false);
const [nvdLoading, setNvdLoading] = useState(false);
const [nvdError, setNvdError] = useState(null);
const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
const [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({});
// Fetch CVEs from API
useEffect(() => {
fetchCVEs();
fetchVendors();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggleCVEExpand = (cveId) => {
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
};
// Refetch when filters change
useEffect(() => {
fetchCVEs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
const lookupNVD = async (cveId) => {
const trimmed = cveId.trim();
if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
setNvdLoading(true);
setNvdError(null);
setNvdAutoFilled(false);
try {
const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, {
credentials: 'include'
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'NVD lookup failed');
}
const data = await response.json();
setNewCVE(prev => ({
...prev,
description: prev.description || data.description,
severity: data.severity,
published_date: data.published_date || prev.published_date
}));
setNvdAutoFilled(true);
} catch (err) {
setNvdError(err.message);
} finally {
setNvdLoading(false);
}
};
const fetchCVEs = async () => {
setLoading(true);
@@ -52,7 +98,9 @@ export default function App() {
if (selectedVendor !== 'All Vendors') params.append('vendor', selectedVendor);
if (selectedSeverity !== 'All Severities') params.append('severity', selectedSeverity);
const response = await fetch(`${API_BASE}/cves?${params}`);
const response = await fetch(`${API_BASE}/cves?${params}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch CVEs');
const data = await response.json();
setCves(data);
@@ -66,7 +114,9 @@ export default function App() {
const fetchVendors = async () => {
try {
const response = await fetch(`${API_BASE}/vendors`);
const response = await fetch(`${API_BASE}/vendors`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch vendors');
const data = await response.json();
setVendors(['All Vendors', ...data]);
@@ -78,9 +128,11 @@ export default function App() {
const fetchDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (cveDocuments[key]) return;
try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`);
const response = await fetch(`${API_BASE}/cves/${cveId}/documents?vendor=${vendor}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch documents');
const data = await response.json();
setCveDocuments(prev => ({ ...prev, [key]: data }));
@@ -91,9 +143,11 @@ export default function App() {
const quickCheckCVEStatus = async () => {
if (!quickCheckCVE.trim()) return;
try {
const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`);
const response = await fetch(`${API_BASE}/cves/check/${quickCheckCVE.trim()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to check CVE');
const data = await response.json();
setQuickCheckResult(data);
@@ -104,7 +158,6 @@ export default function App() {
};
const handleViewDocuments = async (cveId, vendor) => {
const key = `${cveId}-${vendor}`;
if (selectedCVE === cveId && selectedVendorView === vendor) {
setSelectedCVE(null);
setSelectedVendorView(null);
@@ -143,6 +196,7 @@ export default function App() {
const response = await fetch(`${API_BASE}/cves`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(newCVE)
});
@@ -160,6 +214,9 @@ export default function App() {
description: '',
published_date: new Date().toISOString().split('T')[0]
});
setNvdLoading(false);
setNvdError(null);
setNvdAutoFilled(false);
fetchCVEs();
} catch (err) {
alert(`Error: ${err.message}`);
@@ -169,7 +226,7 @@ export default function App() {
const handleFileUpload = async (cveId, vendor) => {
const fileInput = document.createElement('input');
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) => {
const file = e.target.files[0];
@@ -195,6 +252,7 @@ export default function App() {
try {
const response = await fetch(`${API_BASE}/cves/${cveId}/documents`, {
method: 'POST',
credentials: 'include',
body: formData
});
@@ -219,14 +277,15 @@ export default function App() {
if (!window.confirm('Are you sure you want to delete this document?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/documents/${docId}`, {
method: 'DELETE'
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to delete document');
alert('Document deleted successfully!');
const key = `${cveId}-${vendor}`;
delete cveDocuments[key];
@@ -237,6 +296,177 @@ 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
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
fetchVendors();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
// Refetch when filters change
useEffect(() => {
if (isAuthenticated) {
fetchCVEs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedVendor, selectedSeverity]);
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="text-center">
<Loader className="w-12 h-12 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-4">Loading...</p>
</div>
</div>
);
}
// Show login if not authenticated
if (!isAuthenticated) {
return <LoginForm />;
}
// Group CVEs by CVE ID
const groupedCVEs = cves.reduce((acc, cve) => {
if (!acc[cve.cve_id]) {
@@ -257,15 +487,44 @@ export default function App() {
<h1 className="text-3xl font-bold text-gray-900 mb-2">CVE Dashboard</h1>
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
</div>
<button
onClick={() => setShowAddCVE(true)}
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
>
<Plus className="w-5 h-5" />
Add CVE/Vendor
</button>
<div className="flex items-center gap-4">
{canWrite() && (
<button
onClick={() => setShowNvdSync(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md"
>
<RefreshCw className="w-5 h-5" />
Sync with NVD
</button>
)}
{canWrite() && (
<button
onClick={() => setShowAddCVE(true)}
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
>
<Plus className="w-5 h-5" />
Add CVE/Vendor
</button>
)}
<UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} />
</div>
</div>
{/* User Management Modal */}
{showUserManagement && (
<UserManagement onClose={() => setShowUserManagement(false)} />
)}
{/* Audit Log Modal */}
{showAuditLog && (
<AuditLog onClose={() => setShowAuditLog(false)} />
)}
{/* NVD Sync Modal */}
{showNvdSync && (
<NvdSyncModal onClose={() => setShowNvdSync(false)} onSyncComplete={() => fetchCVEs()} />
)}
{/* Add CVE Modal */}
{showAddCVE && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
@@ -274,7 +533,7 @@ export default function App() {
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold text-gray-900">Add CVE Entry</h2>
<button
onClick={() => setShowAddCVE(false)}
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
className="text-gray-400 hover:text-gray-600"
>
<XCircle className="w-6 h-6" />
@@ -293,15 +552,33 @@ export default function App() {
<label className="block text-sm font-medium text-gray-700 mb-1">
CVE ID *
</label>
<input
type="text"
required
placeholder="CVE-2024-1234"
value={newCVE.cve_id}
onChange={(e) => setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()})}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
<div className="relative">
<input
type="text"
required
placeholder="CVE-2024-1234"
value={newCVE.cve_id}
onChange={(e) => { setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()}); setNvdAutoFilled(false); setNvdError(null); }}
onBlur={(e) => lookupNVD(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
{nvdLoading && (
<Loader className="absolute right-3 top-2.5 w-5 h-5 text-[#0476D9] animate-spin" />
)}
</div>
<p className="text-xs text-gray-500 mt-1">Can be the same as existing CVE if adding another vendor</p>
{nvdAutoFilled && (
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Auto-filled from NVD
</p>
)}
{nvdError && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{nvdError}
</p>
)}
</div>
<div>
@@ -371,7 +648,148 @@ export default function App() {
</button>
<button
type="button"
onClick={() => setShowAddCVE(false)}
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</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"
>
Cancel
@@ -427,17 +845,6 @@ export default function App() {
<p><strong>Status:</strong> {vendorInfo.status}</p>
<p><strong>Documents:</strong> {vendorInfo.total_documents} attached</p>
</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>
@@ -544,127 +951,209 @@ export default function App() {
</div>
) : (
<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 className="p-6">
{/* CVE Header */}
<div className="mb-4">
<h3 className="text-2xl font-bold text-gray-900 mb-2">{cveId}</h3>
<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>
{/* Clickable CVE Header */}
<div
className="p-6 cursor-pointer hover:bg-gray-50 transition-colors duration-200 select-none"
onClick={() => toggleCVEExpand(cveId)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<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>
{/* Vendor Entries */}
<div className="space-y-3">
{vendorEntries.map((cve) => {
const key = `${cve.cve_id}-${cve.vendor}`;
const documents = cveDocuments[key] || [];
const isExpanded = selectedCVE === cve.cve_id && selectedVendorView === cve.vendor;
return (
<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-1">
<div className="flex items-center gap-3 mb-2">
<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)}`}>
{cve.severity}
</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'}`}>
{cve.doc_status === 'Complete' ? '✓ Docs Complete' : '⚠ Incomplete'}
</span>
{/* Expanded: Vendor Entries */}
{isCVEExpanded && (
<div className="px-6 pb-6">
<div className="space-y-3">
{vendorEntries.map((cve) => {
const key = `${cve.cve_id}-${cve.vendor}`;
const documents = cveDocuments[key] || [];
const isDocExpanded = selectedCVE === cve.cve_id && selectedVendorView === cve.vendor;
return (
<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-1">
<div className="flex items-center gap-3 mb-2">
<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)}`}>
{cve.severity}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<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 className="flex items-center gap-4 text-sm text-gray-500">
<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 className="flex gap-2">
<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" />
{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>
<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 */}
{isExpanded && (
<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">
<FileText className="w-4 h-4" />
Documents for {cve.vendor} ({documents.length})
</h5>
{documents.length > 0 ? (
<div className="space-y-2">
{documents.map(doc => (
<div
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"
>
<div className="flex items-center gap-3 flex-1">
<input
type="checkbox"
checked={selectedDocuments.includes(doc.id)}
onChange={() => toggleDocumentSelection(doc.id)}
className="w-4 h-4 text-[#0476D9] rounded focus:ring-2 focus:ring-[#0476D9]"
/>
<FileText className="w-5 h-5 text-gray-400" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
<p className="text-xs text-gray-500 capitalize">
{doc.type} {doc.file_size}
{doc.notes && `${doc.notes}`}
</p>
{/* Documents Section */}
{isDocExpanded && (
<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">
<FileText className="w-4 h-4" />
Documents for {cve.vendor} ({documents.length})
</h5>
{documents.length > 0 ? (
<div className="space-y-2">
{documents.map(doc => (
<div
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"
>
<div className="flex items-center gap-3 flex-1">
<input
type="checkbox"
checked={selectedDocuments.includes(doc.id)}
onChange={() => toggleDocumentSelection(doc.id)}
className="w-4 h-4 text-[#0476D9] rounded focus:ring-2 focus:ring-[#0476D9]"
/>
<FileText className="w-5 h-5 text-gray-400" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
<p className="text-xs text-gray-500 capitalize">
{doc.type} {doc.file_size}
{doc.notes && `${doc.notes}`}
</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 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>
<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>
) : (
<p className="text-sm text-gray-500 italic">No documents attached yet</p>
)}
<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>
) : (
<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>
)}

View File

@@ -0,0 +1,307 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Loader, AlertCircle, ChevronLeft, ChevronRight, Search } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const ACTION_BADGES = {
login: { bg: 'bg-green-100', text: 'text-green-800' },
logout: { bg: 'bg-gray-100', text: 'text-gray-800' },
login_failed: { bg: 'bg-red-100', text: 'text-red-800' },
cve_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
cve_update_status: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
document_upload: { bg: 'bg-purple-100', text: 'text-purple-800' },
document_delete: { bg: 'bg-red-100', text: 'text-red-800' },
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
cve_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'];
export default function AuditLog({ onClose }) {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [actions, setActions] = useState([]);
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
// Filters
const [userFilter, setUserFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [entityTypeFilter, setEntityTypeFilter] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const fetchLogs = useCallback(async (page = 1) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ page, limit: 25 });
if (userFilter) params.append('user', userFilter);
if (actionFilter) params.append('action', actionFilter);
if (entityTypeFilter) params.append('entityType', entityTypeFilter);
if (startDate) params.append('startDate', startDate);
if (endDate) params.append('endDate', endDate);
const response = await fetch(`${API_BASE}/audit-logs?${params}`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch audit logs');
const data = await response.json();
setLogs(data.logs);
setPagination(data.pagination);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [userFilter, actionFilter, entityTypeFilter, startDate, endDate]);
const fetchActions = async () => {
try {
const response = await fetch(`${API_BASE}/audit-logs/actions`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setActions(data);
}
} catch (err) {
// Non-critical, ignore
}
};
useEffect(() => {
fetchLogs(1);
fetchActions();
}, [fetchLogs]);
const formatDate = (dateStr) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
};
const formatDetails = (details) => {
if (!details) return '-';
try {
const parsed = typeof details === 'string' ? JSON.parse(details) : details;
return Object.entries(parsed)
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
} catch {
return details;
}
};
const getActionBadge = (action) => {
const style = ACTION_BADGES[action] || { bg: 'bg-gray-100', text: 'text-gray-800' };
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${style.bg} ${style.text}`}>
{action}
</span>
);
};
const handleFilter = (e) => {
e.preventDefault();
fetchLogs(1);
};
const handleReset = () => {
setUserFilter('');
setActionFilter('');
setEntityTypeFilter('');
setStartDate('');
setEndDate('');
};
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-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">Audit Log</h2>
<p className="text-gray-600">Track all user actions across the system</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-2"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Filter Bar */}
<form onSubmit={handleFilter} className="p-4 border-b border-gray-200 bg-gray-50">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Username</label>
<div className="relative">
<Search className="w-4 h-4 text-gray-400 absolute left-2 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search user..."
value={userFilter}
onChange={(e) => setUserFilter(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Action</label>
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
>
<option value="">All Actions</option>
{actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Entity Type</label>
<select
value={entityTypeFilter}
onChange={(e) => setEntityTypeFilter(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
>
<option value="">All Types</option>
{ENTITY_TYPES.map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-2 mt-3">
<button
type="submit"
className="px-4 py-1.5 text-sm bg-[#0476D9] text-white rounded hover:bg-[#0360B8] transition-colors"
>
Apply Filters
</button>
<button
type="button"
onClick={handleReset}
className="px-4 py-1.5 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors"
>
Reset
</button>
</div>
</form>
{/* Content */}
<div className="p-4 overflow-y-auto flex-1">
{loading ? (
<div className="text-center py-12">
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-2">Loading audit logs...</p>
</div>
) : error ? (
<div className="text-center py-12">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
<p className="text-red-600 mt-2">{error}</p>
</div>
) : logs.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">No audit log entries found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Time</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">User</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Action</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Entity</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Details</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">IP Address</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">
{formatDate(log.created_at)}
</td>
<td className="py-2 px-3 font-medium text-gray-900">
{log.username}
</td>
<td className="py-2 px-3">
{getActionBadge(log.action)}
</td>
<td className="py-2 px-3 text-gray-700">
<span className="text-gray-500">{log.entity_type}</span>
{log.entity_id && (
<span className="ml-1 text-gray-900">{log.entity_id}</span>
)}
</td>
<td className="py-2 px-3 text-gray-600 max-w-xs truncate" title={formatDetails(log.details)}>
{formatDetails(log.details)}
</td>
<td className="py-2 px-3 text-gray-500 font-mono text-xs">
{log.ip_address || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="p-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing {((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} entries
</p>
<div className="flex items-center gap-2">
<button
onClick={() => fetchLogs(pagination.page - 1)}
disabled={pagination.page <= 1}
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-700">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
onClick={() => fetchLogs(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { Loader, AlertCircle, Lock, User } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setLoading(false);
};
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[#0476D9] rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">CVE Dashboard</h1>
<p className="text-gray-600 mt-2">Sign in to access the dashboard</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<div className="relative">
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
placeholder="Enter your username"
disabled={loading}
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<Lock className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader className="w-5 h-5 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</button>
</form>
<div className="mt-6 pt-6 border-t border-gray-200">
<p className="text-sm text-gray-500 text-center">
Default admin credentials: admin / admin123
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,509 @@
import React, { useState, useEffect, useRef } from 'react';
import { X, Loader, AlertCircle, CheckCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const FETCH_DELAY_MS = 7000; // 7 seconds between requests (safe for 5 req/30s without API key)
const RETRY_DELAY_MS = 35000; // Wait 35 seconds on 429 before retry
export default function NvdSyncModal({ onClose, onSyncComplete }) {
const [phase, setPhase] = useState('idle'); // idle, fetching, review, applying, done
const [cveIds, setCveIds] = useState([]);
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0, currentId: '' });
const [results, setResults] = useState({}); // { cveId: { nvd: {...}, current: {...}, status: 'found'|'not_found'|'error'|'no_change', error: '' } }
const [descriptionChoices, setDescriptionChoices] = useState({}); // { cveId: 'keep' | 'nvd' }
const [applyResult, setApplyResult] = useState(null);
const [expandedDesc, setExpandedDesc] = useState({});
const abortRef = useRef(null);
// Fetch distinct CVE IDs on mount
useEffect(() => {
(async () => {
try {
const response = await fetch(`${API_BASE}/cves/distinct-ids`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch CVE list');
const data = await response.json();
setCveIds(data);
} catch (err) {
console.error('Error fetching CVE IDs:', err);
}
})();
}, []);
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const fetchNvdData = async () => {
setPhase('fetching');
const controller = new AbortController();
abortRef.current = controller;
const newResults = {};
setFetchProgress({ current: 0, total: cveIds.length, currentId: '' });
// First fetch current data for all CVEs
let currentData = {};
try {
const response = await fetch(`${API_BASE}/cves`, { credentials: 'include', signal: controller.signal });
if (response.ok) {
const allCves = await response.json();
// Group by cve_id, take first entry for description/severity/date
allCves.forEach(cve => {
if (!currentData[cve.cve_id]) {
currentData[cve.cve_id] = {
description: cve.description,
severity: cve.severity,
published_date: cve.published_date
};
}
});
}
} catch (err) {
if (err.name === 'AbortError') { setPhase('idle'); return; }
}
for (let i = 0; i < cveIds.length; i++) {
if (controller.signal.aborted) break;
const cveId = cveIds[i];
setFetchProgress({ current: i + 1, total: cveIds.length, currentId: cveId });
try {
let response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
credentials: 'include',
signal: controller.signal
});
// Handle rate limit with one retry
if (response.status === 429) {
await sleep(RETRY_DELAY_MS);
if (controller.signal.aborted) break;
response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
credentials: 'include',
signal: controller.signal
});
}
if (response.status === 404) {
newResults[cveId] = { status: 'not_found', current: currentData[cveId] || {} };
} else if (!response.ok) {
const data = await response.json().catch(() => ({}));
newResults[cveId] = { status: 'error', error: data.error || `HTTP ${response.status}`, current: currentData[cveId] || {} };
} else {
const nvd = await response.json();
const current = currentData[cveId] || {};
const descChanged = nvd.description && nvd.description !== current.description;
const sevChanged = nvd.severity && nvd.severity !== current.severity;
const dateChanged = nvd.published_date && nvd.published_date !== current.published_date;
if (!descChanged && !sevChanged && !dateChanged) {
newResults[cveId] = { status: 'no_change', nvd, current };
} else {
newResults[cveId] = { status: 'found', nvd, current, descChanged, sevChanged, dateChanged };
}
}
} catch (err) {
if (err.name === 'AbortError') break;
newResults[cveId] = { status: 'error', error: err.message, current: currentData[cveId] || {} };
}
// Update results progressively
setResults({ ...newResults });
// Rate limit delay (skip after last item)
if (i < cveIds.length - 1 && !controller.signal.aborted) {
await sleep(FETCH_DELAY_MS);
}
}
if (!controller.signal.aborted) {
setResults({ ...newResults });
// Default all description choices to 'keep'
const choices = {};
Object.entries(newResults).forEach(([id, r]) => {
if (r.status === 'found' && r.descChanged) {
choices[id] = 'keep';
}
});
setDescriptionChoices(choices);
setPhase('review');
}
};
const cancelFetch = () => {
if (abortRef.current) abortRef.current.abort();
setPhase('idle');
};
const setBulkDescriptionChoice = (choice) => {
const newChoices = {};
Object.keys(descriptionChoices).forEach(id => {
newChoices[id] = choice;
});
setDescriptionChoices(newChoices);
};
const getChangesCount = () => {
let count = 0;
Object.entries(results).forEach(([id, r]) => {
if (r.status === 'found') {
if (r.sevChanged || r.dateChanged || (r.descChanged && descriptionChoices[id] === 'nvd')) {
count++;
}
}
});
return count;
};
const applyChanges = async () => {
setPhase('applying');
const updates = [];
Object.entries(results).forEach(([cveId, r]) => {
if (r.status !== 'found') return;
const update = { cve_id: cveId };
let hasChange = false;
if (r.sevChanged) {
update.severity = r.nvd.severity;
hasChange = true;
}
if (r.dateChanged) {
update.published_date = r.nvd.published_date;
hasChange = true;
}
if (r.descChanged && descriptionChoices[cveId] === 'nvd') {
update.description = r.nvd.description;
hasChange = true;
}
if (hasChange) updates.push(update);
});
if (updates.length === 0) {
setApplyResult({ updated: 0, message: 'No changes to apply' });
setPhase('done');
return;
}
try {
const response = await fetch(`${API_BASE}/cves/nvd-sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ updates })
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Sync failed');
}
const data = await response.json();
setApplyResult(data);
onSyncComplete();
} catch (err) {
setApplyResult({ error: err.message });
}
setPhase('done');
};
const truncate = (str, len = 120) => str && str.length > len ? str.substring(0, len) + '...' : str;
// Summary counts
const foundCount = Object.values(results).filter(r => r.status === 'found').length;
const noChangeCount = Object.values(results).filter(r => r.status === 'no_change').length;
const notFoundCount = Object.values(results).filter(r => r.status === 'not_found').length;
const errorCount = Object.values(results).filter(r => r.status === 'error').length;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<RefreshCw className="w-6 h-6 text-green-600" />
Sync with NVD
</h2>
<p className="text-sm text-gray-500 mt-1">Update existing CVE entries with data from the National Vulnerability Database</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1">
{/* Idle Phase */}
{phase === 'idle' && (
<div className="text-center py-8">
<p className="text-lg text-gray-700 mb-2">
{cveIds.length > 0
? <><strong>{cveIds.length}</strong> unique CVE{cveIds.length !== 1 ? 's' : ''} in database</>
: 'Loading CVE count...'}
</p>
<p className="text-sm text-gray-500 mb-6">
This will fetch data from NVD for each CVE and let you review changes before applying.
Rate-limited to stay within NVD API limits.
</p>
<button
onClick={fetchNvdData}
disabled={cveIds.length === 0}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50 flex items-center gap-2 mx-auto"
>
<RefreshCw className="w-5 h-5" />
Fetch NVD Data
</button>
</div>
)}
{/* Fetching Phase */}
{phase === 'fetching' && (
<div className="py-8">
<div className="text-center mb-6">
<Loader className="w-8 h-8 text-green-600 animate-spin mx-auto mb-3" />
<p className="text-lg text-gray-700">
Fetching CVE {fetchProgress.current} of {fetchProgress.total}
</p>
<p className="text-sm text-gray-500 font-mono mt-1">{fetchProgress.currentId}</p>
</div>
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-3 mb-4">
<div
className="bg-green-600 h-3 rounded-full transition-all duration-300"
style={{ width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
/>
</div>
<div className="text-center">
<button
onClick={cancelFetch}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Review Phase */}
{phase === 'review' && (
<div>
{/* Summary bar */}
<div className="flex flex-wrap gap-3 mb-4 p-3 bg-gray-50 rounded-lg text-sm">
<span className="font-medium">Found: <span className="text-green-700">{foundCount}</span></span>
<span>|</span>
<span>Up to date: <span className="text-gray-600">{noChangeCount}</span></span>
<span>|</span>
<span>Changes: <span className="text-blue-700">{foundCount}</span></span>
<span>|</span>
<span>Not in NVD: <span className="text-gray-400">{notFoundCount}</span></span>
<span>|</span>
<span>Errors: <span className="text-red-600">{errorCount}</span></span>
</div>
{/* Bulk controls */}
{Object.keys(descriptionChoices).length > 0 && (
<div className="flex gap-2 mb-4">
<span className="text-sm text-gray-600 self-center">Descriptions:</span>
<button
onClick={() => setBulkDescriptionChoice('keep')}
className="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100 transition-colors"
>
Keep All Existing
</button>
<button
onClick={() => setBulkDescriptionChoice('nvd')}
className="px-3 py-1 text-xs rounded border border-green-300 text-green-700 hover:bg-green-50 transition-colors"
>
Use All NVD
</button>
</div>
)}
{/* Comparison table */}
<div className="space-y-2">
{Object.entries(results).map(([cveId, r]) => {
if (r.status === 'no_change') {
return (
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
<span className="text-gray-400"></span>
<span className="font-mono font-medium text-gray-500">{cveId}</span>
<span className="text-gray-400">No changes needed</span>
</div>
);
}
if (r.status === 'not_found') {
return (
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
<span className="text-gray-400"></span>
<span className="font-mono font-medium text-gray-400">{cveId}</span>
<span className="text-gray-400 italic">Not found in NVD</span>
</div>
);
}
if (r.status === 'error') {
return (
<div key={cveId} className="flex items-center gap-3 p-3 bg-red-50 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="font-mono font-medium text-gray-700">{cveId}</span>
<span className="text-red-600">{r.error}</span>
</div>
);
}
// status === 'found' — show changes
const isExpanded = expandedDesc[cveId];
return (
<div key={cveId} className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex items-start gap-3">
<CheckCircle className="w-4 h-4 text-green-500 mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-mono font-bold text-gray-900">{cveId}</span>
{r.sevChanged && (
<span className="text-xs">
Severity: <span className="text-red-600">{r.current.severity}</span>
{' → '}
<span className="text-green-700">{r.nvd.severity}</span>
</span>
)}
{r.dateChanged && (
<span className="text-xs">
Date: <span className="text-red-600">{r.current.published_date || '(none)'}</span>
{' → '}
<span className="text-green-700">{r.nvd.published_date}</span>
</span>
)}
</div>
{r.descChanged && (
<div className="mt-2">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-gray-600">Description:</span>
<button
onClick={() => setExpandedDesc(prev => ({ ...prev, [cveId]: !prev[cveId] }))}
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
>
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{isExpanded ? 'Collapse' : 'Expand'}
</button>
</div>
{isExpanded ? (
<div className="space-y-2 text-xs">
<div className="p-2 bg-red-50 rounded border border-red-200">
<span className="font-medium text-red-700">Current: </span>
<span className="text-gray-700">{r.current.description || '(empty)'}</span>
</div>
<div className="p-2 bg-green-50 rounded border border-green-200">
<span className="font-medium text-green-700">NVD: </span>
<span className="text-gray-700">{r.nvd.description}</span>
</div>
</div>
) : (
<p className="text-xs text-gray-500">{truncate(r.nvd.description)}</p>
)}
{/* Description choice */}
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name={`desc-${cveId}`}
checked={descriptionChoices[cveId] === 'keep'}
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'keep' }))}
className="text-blue-600"
/>
Keep existing
</label>
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name={`desc-${cveId}`}
checked={descriptionChoices[cveId] === 'nvd'}
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'nvd' }))}
className="text-green-600"
/>
Use NVD
</label>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Applying Phase */}
{phase === 'applying' && (
<div className="text-center py-12">
<Loader className="w-10 h-10 text-green-600 animate-spin mx-auto mb-4" />
<p className="text-lg text-gray-700">Applying changes...</p>
</div>
)}
{/* Done Phase */}
{phase === 'done' && applyResult && (
<div className="text-center py-8">
{applyResult.error ? (
<>
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-lg text-red-700 font-medium mb-2">Sync failed</p>
<p className="text-sm text-gray-600">{applyResult.error}</p>
</>
) : (
<>
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
<p className="text-lg text-green-700 font-medium mb-2">Sync complete</p>
<p className="text-sm text-gray-600">
{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated
</p>
{applyResult.errors && applyResult.errors.length > 0 && (
<p className="text-sm text-amber-600 mt-2">
{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred
</p>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
{phase === 'review' && (
<>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={applyChanges}
disabled={getChangesCount() === 0}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50"
>
Apply {getChangesCount()} Change{getChangesCount() !== 1 ? 's' : ''}
</button>
</>
)}
{phase === 'done' && (
<button
onClick={onClose}
className="px-6 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
>
Close
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,380 @@
import React, { useState, useEffect } from 'react';
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
export default function UserManagement({ onClose }) {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAddUser, setShowAddUser] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
role: 'viewer'
});
const [formError, setFormError] = useState('');
const [formSuccess, setFormSuccess] = useState('');
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const response = await fetch(`${API_BASE}/users`, {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to fetch users');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setFormError('');
setFormSuccess('');
try {
const url = editingUser
? `${API_BASE}/users/${editingUser.id}`
: `${API_BASE}/users`;
const method = editingUser ? 'PATCH' : 'POST';
const body = { ...formData };
if (editingUser && !body.password) {
delete body.password;
}
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Operation failed');
}
setFormSuccess(editingUser ? 'User updated successfully' : 'User created successfully');
fetchUsers();
setTimeout(() => {
setShowAddUser(false);
setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' });
setFormSuccess('');
}, 1500);
} catch (err) {
setFormError(err.message);
}
};
const handleEdit = (user) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
role: user.role
});
setShowAddUser(true);
setFormError('');
setFormSuccess('');
};
const handleDelete = async (userId) => {
if (!window.confirm('Are you sure you want to delete this user?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/users/${userId}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Delete failed');
}
fetchUsers();
} catch (err) {
alert(err.message);
}
};
const handleToggleActive = async (user) => {
try {
const response = await fetch(`${API_BASE}/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ is_active: !user.is_active })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Update failed');
}
fetchUsers();
} catch (err) {
alert(err.message);
}
};
const getRoleBadgeColor = (role) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800';
case 'editor':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
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-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">User Management</h2>
<p className="text-gray-600">Manage user accounts and permissions</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-2"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">
{!showAddUser && (
<button
onClick={() => {
setShowAddUser(true);
setEditingUser(null);
setFormData({ username: '', email: '', password: '', role: 'viewer' });
setFormError('');
setFormSuccess('');
}}
className="mb-6 px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
>
<Plus className="w-5 h-5" />
Add User
</button>
)}
{showAddUser && (
<div className="mb-6 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-lg font-semibold mb-4">
{editingUser ? 'Edit User' : 'Add New User'}
</h3>
{formError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-sm text-red-700">{formError}</span>
</div>
)}
{formSuccess && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-sm text-green-700">{formSuccess}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username *
</label>
<div className="relative">
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<div className="relative">
<Mail className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password {editingUser ? '(leave blank to keep current)' : '*'}
</label>
<input
type="password"
required={!editingUser}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: 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">
Role *
</label>
<div className="relative">
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
>
<option value="viewer">Viewer (read-only)</option>
<option value="editor">Editor (can add CVEs, upload docs)</option>
<option value="admin">Admin (full access)</option>
</select>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors"
>
{editingUser ? 'Update User' : 'Create User'}
</button>
<button
type="button"
onClick={() => {
setShowAddUser(false);
setEditingUser(null);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
)}
{loading ? (
<div className="text-center py-12">
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
<p className="text-gray-600 mt-2">Loading users...</p>
</div>
) : error ? (
<div className="text-center py-12">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
<p className="text-red-600 mt-2">{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Role</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<div>
<p className="font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
</span>
</td>
<td className="py-3 px-4">
<button
onClick={() => handleToggleActive(user)}
disabled={user.id === currentUser.id}
className={`px-2 py-1 rounded text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
} ${user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-80'}`}
>
{user.is_active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="py-3 px-4 text-sm text-gray-500">
{user.last_login
? new Date(user.last_login).toLocaleString()
: 'Never'}
</td>
<td className="py-3 px-4">
<div className="flex justify-end gap-2">
<button
onClick={() => handleEdit(user)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
title="Edit user"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === currentUser.id}
className={`p-2 text-red-600 hover:bg-red-50 rounded ${
user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : ''
}`}
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import React, { useState, useRef, useEffect } from 'react';
import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
export default function UserMenu({ onManageUsers, onAuditLog }) {
const { user, logout, isAdmin } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef(null);
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getRoleBadgeColor = (role) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800';
case 'editor':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const handleLogout = async () => {
setIsOpen(false);
await logout();
};
const handleManageUsers = () => {
setIsOpen(false);
if (onManageUsers) {
onManageUsers();
}
};
const handleAuditLog = () => {
setIsOpen(false);
if (onAuditLog) {
onAuditLog();
}
};
if (!user) return null;
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="w-8 h-8 bg-[#0476D9] rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<div className="text-left hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
</div>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-sm font-medium text-gray-900">{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<span className={`inline-block mt-2 px-2 py-1 rounded text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{user.role.charAt(0).toUpperCase() + user.role.slice(1)}
</span>
</div>
{isAdmin() && (
<>
<button
onClick={handleManageUsers}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
>
<Shield className="w-4 h-4" />
Manage Users
</button>
<button
onClick={handleAuditLog}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
>
<Clock className="w-4 h-4" />
Audit Log
</button>
</>
)}
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
Sign Out
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Check if user is authenticated on mount
const checkAuth = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/auth/me`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
setUser(null);
}
} catch (err) {
console.error('Auth check error:', err);
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
checkAuth();
}, [checkAuth]);
// Login function
const login = async (username, password) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
setUser(data.user);
return { success: true };
} catch (err) {
setError(err.message);
return { success: false, error: err.message };
}
};
// Logout function
const logout = async () => {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} catch (err) {
console.error('Logout error:', err);
}
setUser(null);
};
// Check if user has a specific role
const hasRole = (...roles) => {
return user && roles.includes(user.role);
};
// Check if user can perform write operations (editor or admin)
const canWrite = () => hasRole('editor', 'admin');
// Check if user is admin
const isAdmin = () => hasRole('admin');
const value = {
user,
loading,
error,
login,
logout,
checkAuth,
hasRole,
canWrite,
isAdmin,
isAuthenticated: !!user
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { AuthProvider } from './contexts/AuthContext';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);

View File

@@ -10,6 +10,8 @@
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"express": "^5.2.1",

297
plan.md Normal file
View 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

View File

@@ -1,18 +1,37 @@
#!/bin/bash
echo "Stopping CVE Dashboard servers..."
# Kill by PID files if they exist
if [ -f backend.pid ]; then
kill $(cat backend.pid) 2>/dev/null
rm backend.pid
echo "✓ Backend stopped"
echo "✓ Backend stopped (via pid)"
fi
if [ -f frontend.pid ]; then
kill $(cat frontend.pid) 2>/dev/null
rm frontend.pid
echo "✓ Frontend stopped"
echo "✓ Frontend stopped (via pid)"
fi
pkill -f "node server.js"
pkill -f "react-scripts start"
echo "All servers stopped"
# Force kill all node processes related to this project
pkill -9 -f "node.*server.js" 2>/dev/null
pkill -9 -f "react-scripts" 2>/dev/null
pkill -9 -f "webpack" 2>/dev/null
# Wait a moment and verify
sleep 1
# Check if any are still running
if pgrep -f "react-scripts" > /dev/null; then
echo "⚠ Some React processes still running, force killing..."
pkill -9 -f "react-scripts"
sleep 1
fi
if pgrep -f "node.*server.js" > /dev/null; then
echo "⚠ Backend still running, force killing..."
pkill -9 -f "node.*server.js"
fi
echo "✓ All servers stopped"

164
test_cases_auth.md Normal file
View File

@@ -0,0 +1,164 @@
# Authentication Feature - Test Cases
**Feature Branch:** feature/login
**Date:** 2026-01-28
**Tester:** _______________
---
## Pre-Test Setup
- [ ] Backend server running on port 3001
- [ ] Frontend server running on port 3000
- [ ] Database has been set up with `node setup.js`
- [ ] Can access http://[SERVER_IP]:3000 in browser
---
## 1. Login Page Display
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 1.1 | Navigate to app URL when not logged in | Login page displays | |
| 1.2 | Login page shows username field | Field is visible and editable | |
| 1.3 | Login page shows password field | Field is visible and editable | |
| 1.4 | Login page shows "Sign In" button | Button is visible | |
| 1.5 | Default credentials hint is shown | Shows "admin / admin123" | |
---
## 2. Login Functionality
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 2.1 | Login with valid credentials (admin/admin123) | Redirects to dashboard | |
| 2.2 | Login with invalid username | Shows "Invalid username or password" | |
| 2.3 | Login with invalid password | Shows "Invalid username or password" | |
| 2.4 | Login with empty username | Form validation prevents submit | |
| 2.5 | Login with empty password | Form validation prevents submit | |
| 2.6 | Press Enter in password field | Submits form (same as clicking Sign In) | |
---
## 3. Session Persistence
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 3.1 | Refresh page after login | Stays logged in, dashboard displays | |
| 3.2 | Open new browser tab to same URL | Already logged in | |
| 3.3 | Close browser, reopen, navigate to app | Still logged in (within 24hrs) | |
---
## 4. Logout
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 4.1 | Click user menu in header | Dropdown menu appears | |
| 4.2 | Click "Sign Out" in dropdown | Returns to login page | |
| 4.3 | After logout, try to access dashboard URL directly | Redirects to login page | |
| 4.4 | After logout, check browser cookies | session_id cookie is cleared | |
---
## 5. User Menu Display
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 5.1 | User menu shows username | Displays "admin" | |
| 5.2 | User menu shows role | Displays "admin" role | |
| 5.3 | User menu dropdown shows email | Shows admin@localhost | |
| 5.4 | Admin user sees "Manage Users" option | Option is visible | |
---
## 6. Role-Based UI - Admin Role
*Login as: admin/admin123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 6.1 | "Add CVE/Vendor" button in header | Visible | |
| 6.2 | "Upload Document" button on CVE records | Visible | |
| 6.3 | "Delete" button on documents | Visible | |
| 6.4 | "Manage Users" in user menu | Visible | |
| 6.5 | Can open User Management panel | Panel opens | |
---
## 7. User Management (Admin)
*Login as: admin/admin123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 7.1 | Open User Management panel | Shows list of users | |
| 7.2 | Click "Add User" button | Add user form appears | |
| 7.3 | Create user: editor1 / editor1@test.com / password123 / Editor | User created successfully | |
| 7.4 | Create user: viewer1 / viewer1@test.com / password123 / Viewer | User created successfully | |
| 7.5 | Edit existing user (change email) | Changes saved | |
| 7.6 | Toggle user active status | Status changes | |
| 7.7 | Delete a user (not self) | User deleted | |
| 7.8 | Try to delete own account | Error: "Cannot delete your own account" | |
| 7.9 | Try to deactivate own account | Error: "Cannot deactivate your own account" | |
| 7.10 | Try to remove own admin role | Error: "Cannot remove your own admin role" | |
| 7.11 | Create duplicate username | Error: "Username or email already exists" | |
---
## 8. Role-Based UI - Editor Role
*Logout and login as: editor1/password123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 8.1 | "Add CVE/Vendor" button in header | Visible | |
| 8.2 | "Upload Document" button on CVE records | Visible | |
| 8.3 | "Delete" button on documents | NOT visible | |
| 8.4 | "Manage Users" in user menu | NOT visible | |
| 8.5 | Can add a new CVE | CVE created successfully | |
| 8.6 | Can upload a document | Document uploaded successfully | |
---
## 9. Role-Based UI - Viewer Role
*Logout and login as: viewer1/password123*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 9.1 | "Add CVE/Vendor" button in header | NOT visible | |
| 9.2 | "Upload Document" button on CVE records | NOT visible | |
| 9.3 | "Delete" button on documents | NOT visible | |
| 9.4 | "Manage Users" in user menu | NOT visible | |
| 9.5 | Can view CVE list | CVEs display correctly | |
| 9.6 | Can view documents (click View) | Documents accessible | |
| 9.7 | Can use Quick CVE Status Check | Search works | |
| 9.8 | Can use filters (vendor, severity) | Filters work | |
---
## 10. Deactivated User
*As admin, deactivate viewer1 account*
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 10.1 | Try to login as deactivated user | Error: "Account is disabled" | |
| 10.2 | Reactivate user (as admin) | User can login again | |
---
## 11. Error Handling
| # | Test Case | Expected Result | Pass/Fail |
|---|-----------|-----------------|-----------|
| 11.1 | Stop backend, try to login | Shows "Failed to fetch" or connection error | |
| 11.2 | Backend returns 500 error | Error message displayed to user | |
---
## Sign-Off
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Tester | | | |
| Developer | | | |
### Notes / Issues Found:
```
```
### Final Status: [ ] PASS [ ] FAIL