Compare commits
3 Commits
fbdf05392a
...
da109a6f8b
| Author | SHA1 | Date | |
|---|---|---|---|
| da109a6f8b | |||
| 260ae48f77 | |||
| 1a578b23c1 |
84
.gitea/issue_template/enhancement.yaml
Normal file
84
.gitea/issue_template/enhancement.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Enhancement
|
||||||
|
about: Suggest an improvement to an existing feature or functionality
|
||||||
|
title: "[Enhancement] "
|
||||||
|
labels:
|
||||||
|
- kind/enhancement
|
||||||
|
- status/triage
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to suggest an improvement! This template is for enhancements to **existing** features. If you'd like to request a brand new feature, please use the Feature Request template instead.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: Current Behavior
|
||||||
|
description: Describe how the existing feature currently works.
|
||||||
|
placeholder: "Currently, when I do X, it works like..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposed-improvement
|
||||||
|
attributes:
|
||||||
|
label: Proposed Improvement
|
||||||
|
description: Describe how you'd like the existing feature to be improved.
|
||||||
|
placeholder: "I'd like it to also do Y, or behave differently by..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: Use Case
|
||||||
|
description: Why would this improvement be valuable? What problem does it solve?
|
||||||
|
placeholder: "This would help because..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Area of the Application
|
||||||
|
description: Which part of the application does this enhancement relate to?
|
||||||
|
options:
|
||||||
|
- Dashboard / CVE List
|
||||||
|
- CVE Details
|
||||||
|
- Document Management
|
||||||
|
- User Management
|
||||||
|
- Authentication
|
||||||
|
- Audit Logging
|
||||||
|
- API
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: Priority
|
||||||
|
description: How important is this enhancement to your workflow?
|
||||||
|
options:
|
||||||
|
- Nice to have
|
||||||
|
- Important
|
||||||
|
- Critical
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives or Workarounds
|
||||||
|
description: Are there any current workarounds or alternative approaches you've considered?
|
||||||
|
placeholder: "Currently I work around this by..."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, screenshots, or mockups about the enhancement here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
222
TEST_PLAN_AUDIT_LOG.md
Normal file
222
TEST_PLAN_AUDIT_LOG.md
Normal 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** | |
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
API_HOST=localhost
|
API_HOST=localhost
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||||
|
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
|
NVD_API_KEY=
|
||||||
|
|||||||
21
backend/helpers/auditLog.js
Normal file
21
backend/helpers/auditLog.js
Normal 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;
|
||||||
96
backend/migrate-audit-log.js
Normal file
96
backend/migrate-audit-log.js
Normal 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();
|
||||||
114
backend/routes/auditLog.js
Normal file
114
backend/routes/auditLog.js
Normal 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;
|
||||||
@@ -3,7 +3,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
function createAuthRouter(db) {
|
function createAuthRouter(db, logAudit) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
@@ -28,16 +28,43 @@ function createAuthRouter(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
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' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.is_active) {
|
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' });
|
return res.status(401).json({ error: 'Account is disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||||
if (!validPassword) {
|
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' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +104,16 @@ function createAuthRouter(db) {
|
|||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
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({
|
res.json({
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
user: {
|
user: {
|
||||||
@@ -97,6 +134,17 @@ function createAuthRouter(db) {
|
|||||||
const sessionId = req.cookies?.session_id;
|
const sessionId = req.cookies?.session_id;
|
||||||
|
|
||||||
if (sessionId) {
|
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
|
// Delete session from database
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
db.run(
|
db.run(
|
||||||
@@ -105,6 +153,18 @@ function createAuthRouter(db) {
|
|||||||
() => resolve()
|
() => 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
|
// Clear cookie
|
||||||
|
|||||||
94
backend/routes/nvdLookup.js
Normal file
94
backend/routes/nvdLookup.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// NVD CVE Lookup Routes
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/;
|
||||||
|
|
||||||
|
function createNvdLookupRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// Lookup CVE details from NVD API 2.0
|
||||||
|
router.get('/lookup/:cveId', async (req, res) => {
|
||||||
|
const { cveId } = req.params;
|
||||||
|
|
||||||
|
if (!CVE_ID_PATTERN.test(cveId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encodeURIComponent(cveId)}`;
|
||||||
|
const headers = {};
|
||||||
|
if (process.env.NVD_API_KEY) {
|
||||||
|
headers['apiKey'] = process.env.NVD_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return res.status(429).json({ error: 'NVD API rate limit exceeded. Try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return res.status(502).json({ error: `NVD API returned status ${response.status}.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'CVE not found in NVD.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vuln = data.vulnerabilities[0].cve;
|
||||||
|
|
||||||
|
// Extract English description
|
||||||
|
const descriptionEntry = vuln.descriptions?.find(d => d.lang === 'en');
|
||||||
|
const description = descriptionEntry ? descriptionEntry.value : '';
|
||||||
|
|
||||||
|
// Extract severity with cascade: CVSS v3.1 → v3.0 → v2.0
|
||||||
|
let severity = null;
|
||||||
|
const metrics = vuln.metrics || {};
|
||||||
|
|
||||||
|
if (metrics.cvssMetricV31 && metrics.cvssMetricV31.length > 0) {
|
||||||
|
severity = metrics.cvssMetricV31[0].cvssData?.baseSeverity;
|
||||||
|
} else if (metrics.cvssMetricV30 && metrics.cvssMetricV30.length > 0) {
|
||||||
|
severity = metrics.cvssMetricV30[0].cvssData?.baseSeverity;
|
||||||
|
} else if (metrics.cvssMetricV2 && metrics.cvssMetricV2.length > 0) {
|
||||||
|
severity = metrics.cvssMetricV2[0].baseSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map NVD severity strings to app levels
|
||||||
|
const severityMap = {
|
||||||
|
'CRITICAL': 'Critical',
|
||||||
|
'HIGH': 'High',
|
||||||
|
'MEDIUM': 'Medium',
|
||||||
|
'LOW': 'Low'
|
||||||
|
};
|
||||||
|
severity = severity ? (severityMap[severity.toUpperCase()] || 'Medium') : 'Medium';
|
||||||
|
|
||||||
|
// Extract published date (YYYY-MM-DD)
|
||||||
|
const publishedRaw = vuln.published;
|
||||||
|
const published_date = publishedRaw ? publishedRaw.split('T')[0] : '';
|
||||||
|
|
||||||
|
res.json({ description, severity, published_date });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||||
|
return res.status(504).json({ error: 'NVD API request timed out.' });
|
||||||
|
}
|
||||||
|
console.error('NVD lookup error:', err);
|
||||||
|
res.status(502).json({ error: 'Failed to reach NVD API.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createNvdLookupRouter;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
function createUsersRouter(db, requireAuth, requireRole) {
|
function createUsersRouter(db, requireAuth, requireRole, logAudit) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// All routes require admin role
|
// All routes require admin role
|
||||||
@@ -81,6 +81,16 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
res.status(201).json({
|
||||||
message: 'User created successfully',
|
message: 'User created successfully',
|
||||||
user: {
|
user: {
|
||||||
@@ -160,6 +170,23 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 user was deactivated, delete their sessions
|
||||||
if (is_active === false) {
|
if (is_active === false) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@@ -187,6 +214,14 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
// Delete sessions first (foreign key)
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
|
db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve());
|
||||||
@@ -204,6 +239,16 @@ function createUsersRouter(db, requireAuth, requireRole) {
|
|||||||
return res.status(404).json({ error: 'User not found' });
|
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' });
|
res.json({ message: 'User deleted successfully' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete user error:', err);
|
console.error('Delete user error:', err);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const fs = require('fs');
|
|||||||
const { requireAuth, requireRole } = require('./middleware/auth');
|
const { requireAuth, requireRole } = require('./middleware/auth');
|
||||||
const createAuthRouter = require('./routes/auth');
|
const createAuthRouter = require('./routes/auth');
|
||||||
const createUsersRouter = require('./routes/users');
|
const createUsersRouter = require('./routes/users');
|
||||||
|
const createAuditLogRouter = require('./routes/auditLog');
|
||||||
|
const logAudit = require('./helpers/auditLog');
|
||||||
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -46,10 +49,16 @@ const db = new sqlite3.Database('./cve_database.db', (err) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Auth routes (public)
|
// Auth routes (public)
|
||||||
app.use('/api/auth', createAuthRouter(db));
|
app.use('/api/auth', createAuthRouter(db, logAudit));
|
||||||
|
|
||||||
// User management routes (admin only)
|
// User management routes (admin only)
|
||||||
app.use('/api/users', createUsersRouter(db, requireAuth, requireRole));
|
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
|
// Simple storage - upload to temp directory first
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -120,6 +129,14 @@ app.get('/api/cves', requireAuth(db), (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get distinct CVE IDs for NVD sync (authenticated users)
|
||||||
|
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
||||||
|
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
||||||
|
if (err) return res.status(500).json({ error: err.message });
|
||||||
|
res.json(rows.map(r => r.cve_id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
||||||
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
||||||
const { cveId } = req.params;
|
const { cveId } = req.params;
|
||||||
@@ -215,10 +232,19 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res
|
|||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
res.json({
|
logAudit(db, {
|
||||||
id: this.lastID,
|
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,
|
cve_id,
|
||||||
message: `CVE created successfully for vendor: ${vendor}`
|
message: `CVE created successfully for vendor: ${vendor}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -230,16 +256,91 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm
|
|||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
|
|
||||||
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
||||||
|
|
||||||
db.run(query, [
|
db.run(query, [status, cveId], function(err) {
|
||||||
vendor,status, cveId], function(err) {
|
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
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 });
|
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) {
|
||||||
|
errors.push({ cve_id: entry.cve_id, error: err.message });
|
||||||
|
} else {
|
||||||
|
updated += this.changes;
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
if (completed === updates.length) sendResponse();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResponse() {
|
||||||
|
logAudit(db, {
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'cve_nvd_sync',
|
||||||
|
entityType: 'cve',
|
||||||
|
entityId: null,
|
||||||
|
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
||||||
|
ipAddress: req.ip
|
||||||
|
});
|
||||||
|
const result = { message: 'NVD sync completed', updated };
|
||||||
|
if (errors.length > 0) result.errors = errors;
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ========== DOCUMENT ENDPOINTS ==========
|
// ========== DOCUMENT ENDPOINTS ==========
|
||||||
|
|
||||||
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
||||||
@@ -329,6 +430,15 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a
|
|||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
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({
|
res.json({
|
||||||
id: this.lastID,
|
id: this.lastID,
|
||||||
message: 'Document uploaded successfully',
|
message: 'Document uploaded successfully',
|
||||||
@@ -359,6 +469,15 @@ app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, re
|
|||||||
if (err) {
|
if (err) {
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
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' });
|
res.json({ message: 'Document deleted successfully' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ function initializeDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
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
|
INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES
|
||||||
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
|
('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'),
|
||||||
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
|
('Microsoft', 'screenshot', 0, 'Proof of patch application'),
|
||||||
@@ -244,7 +262,7 @@ function displaySummary() {
|
|||||||
console.log('╚════════════════════════════════════════════════════════╝');
|
console.log('╚════════════════════════════════════════════════════════╝');
|
||||||
console.log('\n📊 What was created:');
|
console.log('\n📊 What was created:');
|
||||||
console.log(' ✓ SQLite database (cve_database.db)');
|
console.log(' ✓ SQLite database (cve_database.db)');
|
||||||
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions');
|
console.log(' ✓ Tables: cves, documents, required_documents, users, sessions, audit_logs');
|
||||||
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
|
console.log(' ✓ Multi-vendor support with UNIQUE(cve_id, vendor)');
|
||||||
console.log(' ✓ Vendor column in documents table');
|
console.log(' ✓ Vendor column in documents table');
|
||||||
console.log(' ✓ User authentication with session-based auth');
|
console.log(' ✓ User authentication with session-based auth');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus } from 'lucide-react';
|
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
import UserManagement from './components/UserManagement';
|
import UserManagement from './components/UserManagement';
|
||||||
|
import AuditLog from './components/AuditLog';
|
||||||
|
import NvdSyncModal from './components/NvdSyncModal';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost:3001';
|
||||||
@@ -27,6 +29,8 @@ export default function App() {
|
|||||||
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
const [quickCheckResult, setQuickCheckResult] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
|
const [showNvdSync, setShowNvdSync] = useState(false);
|
||||||
const [newCVE, setNewCVE] = useState({
|
const [newCVE, setNewCVE] = useState({
|
||||||
cve_id: '',
|
cve_id: '',
|
||||||
vendor: '',
|
vendor: '',
|
||||||
@@ -35,6 +39,42 @@ export default function App() {
|
|||||||
published_date: new Date().toISOString().split('T')[0]
|
published_date: new Date().toISOString().split('T')[0]
|
||||||
});
|
});
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
|
const [nvdLoading, setNvdLoading] = useState(false);
|
||||||
|
const [nvdError, setNvdError] = useState(null);
|
||||||
|
const [nvdAutoFilled, setNvdAutoFilled] = useState(false);
|
||||||
|
|
||||||
|
const lookupNVD = async (cveId) => {
|
||||||
|
const trimmed = cveId.trim();
|
||||||
|
if (!/^CVE-\d{4}-\d{4,}$/.test(trimmed)) return;
|
||||||
|
|
||||||
|
setNvdLoading(true);
|
||||||
|
setNvdError(null);
|
||||||
|
setNvdAutoFilled(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(trimmed)}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'NVD lookup failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setNewCVE(prev => ({
|
||||||
|
...prev,
|
||||||
|
description: prev.description || data.description,
|
||||||
|
severity: data.severity,
|
||||||
|
published_date: data.published_date || prev.published_date
|
||||||
|
}));
|
||||||
|
setNvdAutoFilled(true);
|
||||||
|
} catch (err) {
|
||||||
|
setNvdError(err.message);
|
||||||
|
} finally {
|
||||||
|
setNvdLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchCVEs = async () => {
|
const fetchCVEs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -161,6 +201,9 @@ export default function App() {
|
|||||||
description: '',
|
description: '',
|
||||||
published_date: new Date().toISOString().split('T')[0]
|
published_date: new Date().toISOString().split('T')[0]
|
||||||
});
|
});
|
||||||
|
setNvdLoading(false);
|
||||||
|
setNvdError(null);
|
||||||
|
setNvdAutoFilled(false);
|
||||||
fetchCVEs();
|
fetchCVEs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Error: ${err.message}`);
|
alert(`Error: ${err.message}`);
|
||||||
@@ -295,6 +338,15 @@ export default function App() {
|
|||||||
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
|
<p className="text-gray-600">Query vulnerabilities, manage vendors, and attach documentation</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{canWrite() && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNvdSync(true)}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 shadow-md"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
Sync with NVD
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canWrite() && (
|
{canWrite() && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCVE(true)}
|
onClick={() => setShowAddCVE(true)}
|
||||||
@@ -304,7 +356,7 @@ export default function App() {
|
|||||||
Add CVE/Vendor
|
Add CVE/Vendor
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<UserMenu onManageUsers={() => setShowUserManagement(true)} />
|
<UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -313,6 +365,16 @@ export default function App() {
|
|||||||
<UserManagement onClose={() => setShowUserManagement(false)} />
|
<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 */}
|
{/* Add CVE Modal */}
|
||||||
{showAddCVE && (
|
{showAddCVE && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
@@ -321,7 +383,7 @@ export default function App() {
|
|||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Add CVE Entry</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Add CVE Entry</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddCVE(false)}
|
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<XCircle className="w-6 h-6" />
|
<XCircle className="w-6 h-6" />
|
||||||
@@ -340,15 +402,33 @@ export default function App() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
CVE ID *
|
CVE ID *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<input
|
||||||
required
|
type="text"
|
||||||
placeholder="CVE-2024-1234"
|
required
|
||||||
value={newCVE.cve_id}
|
placeholder="CVE-2024-1234"
|
||||||
onChange={(e) => setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()})}
|
value={newCVE.cve_id}
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
onChange={(e) => { setNewCVE({...newCVE, cve_id: e.target.value.toUpperCase()}); setNvdAutoFilled(false); setNvdError(null); }}
|
||||||
/>
|
onBlur={(e) => lookupNVD(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{nvdLoading && (
|
||||||
|
<Loader className="absolute right-3 top-2.5 w-5 h-5 text-[#0476D9] animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">Can be the same as existing CVE if adding another vendor</p>
|
<p className="text-xs text-gray-500 mt-1">Can be the same as existing CVE if adding another vendor</p>
|
||||||
|
{nvdAutoFilled && (
|
||||||
|
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Auto-filled from NVD
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{nvdError && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{nvdError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -418,7 +498,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAddCVE(false)}
|
onClick={() => { setShowAddCVE(false); setNvdLoading(false); setNvdError(null); setNvdAutoFilled(false); }}
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
305
frontend/src/components/AuditLog.js
Normal file
305
frontend/src/components/AuditLog.js
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
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_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>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
frontend/src/components/NvdSyncModal.js
Normal file
509
frontend/src/components/NvdSyncModal.js
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, Loader, AlertCircle, CheckCircle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
const FETCH_DELAY_MS = 7000; // 7 seconds between requests (safe for 5 req/30s without API key)
|
||||||
|
const RETRY_DELAY_MS = 35000; // Wait 35 seconds on 429 before retry
|
||||||
|
|
||||||
|
export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||||
|
const [phase, setPhase] = useState('idle'); // idle, fetching, review, applying, done
|
||||||
|
const [cveIds, setCveIds] = useState([]);
|
||||||
|
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0, currentId: '' });
|
||||||
|
const [results, setResults] = useState({}); // { cveId: { nvd: {...}, current: {...}, status: 'found'|'not_found'|'error'|'no_change', error: '' } }
|
||||||
|
const [descriptionChoices, setDescriptionChoices] = useState({}); // { cveId: 'keep' | 'nvd' }
|
||||||
|
const [applyResult, setApplyResult] = useState(null);
|
||||||
|
const [expandedDesc, setExpandedDesc] = useState({});
|
||||||
|
const abortRef = useRef(null);
|
||||||
|
|
||||||
|
// Fetch distinct CVE IDs on mount
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves/distinct-ids`, { credentials: 'include' });
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch CVE list');
|
||||||
|
const data = await response.json();
|
||||||
|
setCveIds(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching CVE IDs:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const fetchNvdData = async () => {
|
||||||
|
setPhase('fetching');
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
const newResults = {};
|
||||||
|
setFetchProgress({ current: 0, total: cveIds.length, currentId: '' });
|
||||||
|
|
||||||
|
// First fetch current data for all CVEs
|
||||||
|
let currentData = {};
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves`, { credentials: 'include', signal: controller.signal });
|
||||||
|
if (response.ok) {
|
||||||
|
const allCves = await response.json();
|
||||||
|
// Group by cve_id, take first entry for description/severity/date
|
||||||
|
allCves.forEach(cve => {
|
||||||
|
if (!currentData[cve.cve_id]) {
|
||||||
|
currentData[cve.cve_id] = {
|
||||||
|
description: cve.description,
|
||||||
|
severity: cve.severity,
|
||||||
|
published_date: cve.published_date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') { setPhase('idle'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < cveIds.length; i++) {
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
|
|
||||||
|
const cveId = cveIds[i];
|
||||||
|
setFetchProgress({ current: i + 1, total: cveIds.length, currentId: cveId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle rate limit with one retry
|
||||||
|
if (response.status === 429) {
|
||||||
|
await sleep(RETRY_DELAY_MS);
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
|
response = await fetch(`${API_BASE}/nvd/lookup/${encodeURIComponent(cveId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
newResults[cveId] = { status: 'not_found', current: currentData[cveId] || {} };
|
||||||
|
} else if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
newResults[cveId] = { status: 'error', error: data.error || `HTTP ${response.status}`, current: currentData[cveId] || {} };
|
||||||
|
} else {
|
||||||
|
const nvd = await response.json();
|
||||||
|
const current = currentData[cveId] || {};
|
||||||
|
|
||||||
|
const descChanged = nvd.description && nvd.description !== current.description;
|
||||||
|
const sevChanged = nvd.severity && nvd.severity !== current.severity;
|
||||||
|
const dateChanged = nvd.published_date && nvd.published_date !== current.published_date;
|
||||||
|
|
||||||
|
if (!descChanged && !sevChanged && !dateChanged) {
|
||||||
|
newResults[cveId] = { status: 'no_change', nvd, current };
|
||||||
|
} else {
|
||||||
|
newResults[cveId] = { status: 'found', nvd, current, descChanged, sevChanged, dateChanged };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') break;
|
||||||
|
newResults[cveId] = { status: 'error', error: err.message, current: currentData[cveId] || {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update results progressively
|
||||||
|
setResults({ ...newResults });
|
||||||
|
|
||||||
|
// Rate limit delay (skip after last item)
|
||||||
|
if (i < cveIds.length - 1 && !controller.signal.aborted) {
|
||||||
|
await sleep(FETCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setResults({ ...newResults });
|
||||||
|
// Default all description choices to 'keep'
|
||||||
|
const choices = {};
|
||||||
|
Object.entries(newResults).forEach(([id, r]) => {
|
||||||
|
if (r.status === 'found' && r.descChanged) {
|
||||||
|
choices[id] = 'keep';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setDescriptionChoices(choices);
|
||||||
|
setPhase('review');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelFetch = () => {
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
setPhase('idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBulkDescriptionChoice = (choice) => {
|
||||||
|
const newChoices = {};
|
||||||
|
Object.keys(descriptionChoices).forEach(id => {
|
||||||
|
newChoices[id] = choice;
|
||||||
|
});
|
||||||
|
setDescriptionChoices(newChoices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangesCount = () => {
|
||||||
|
let count = 0;
|
||||||
|
Object.entries(results).forEach(([id, r]) => {
|
||||||
|
if (r.status === 'found') {
|
||||||
|
if (r.sevChanged || r.dateChanged || (r.descChanged && descriptionChoices[id] === 'nvd')) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyChanges = async () => {
|
||||||
|
setPhase('applying');
|
||||||
|
const updates = [];
|
||||||
|
|
||||||
|
Object.entries(results).forEach(([cveId, r]) => {
|
||||||
|
if (r.status !== 'found') return;
|
||||||
|
|
||||||
|
const update = { cve_id: cveId };
|
||||||
|
let hasChange = false;
|
||||||
|
|
||||||
|
if (r.sevChanged) {
|
||||||
|
update.severity = r.nvd.severity;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
if (r.dateChanged) {
|
||||||
|
update.published_date = r.nvd.published_date;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
if (r.descChanged && descriptionChoices[cveId] === 'nvd') {
|
||||||
|
update.description = r.nvd.description;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChange) updates.push(update);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
setApplyResult({ updated: 0, message: 'No changes to apply' });
|
||||||
|
setPhase('done');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/cves/nvd-sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ updates })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Sync failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setApplyResult(data);
|
||||||
|
onSyncComplete();
|
||||||
|
} catch (err) {
|
||||||
|
setApplyResult({ error: err.message });
|
||||||
|
}
|
||||||
|
setPhase('done');
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (str, len = 120) => str && str.length > len ? str.substring(0, len) + '...' : str;
|
||||||
|
|
||||||
|
// Summary counts
|
||||||
|
const foundCount = Object.values(results).filter(r => r.status === 'found').length;
|
||||||
|
const noChangeCount = Object.values(results).filter(r => r.status === 'no_change').length;
|
||||||
|
const notFoundCount = Object.values(results).filter(r => r.status === 'not_found').length;
|
||||||
|
const errorCount = Object.values(results).filter(r => r.status === 'error').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-6 h-6 text-green-600" />
|
||||||
|
Sync with NVD
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Update existing CVE entries with data from the National Vulnerability Database</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 overflow-y-auto flex-1">
|
||||||
|
{/* Idle Phase */}
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-lg text-gray-700 mb-2">
|
||||||
|
{cveIds.length > 0
|
||||||
|
? <><strong>{cveIds.length}</strong> unique CVE{cveIds.length !== 1 ? 's' : ''} in database</>
|
||||||
|
: 'Loading CVE count...'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
This will fetch data from NVD for each CVE and let you review changes before applying.
|
||||||
|
Rate-limited to stay within NVD API limits.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchNvdData}
|
||||||
|
disabled={cveIds.length === 0}
|
||||||
|
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50 flex items-center gap-2 mx-auto"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
Fetch NVD Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fetching Phase */}
|
||||||
|
{phase === 'fetching' && (
|
||||||
|
<div className="py-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<Loader className="w-8 h-8 text-green-600 animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-lg text-gray-700">
|
||||||
|
Fetching CVE {fetchProgress.current} of {fetchProgress.total}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 font-mono mt-1">{fetchProgress.currentId}</p>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3 mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-green-600 h-3 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={cancelFetch}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Phase */}
|
||||||
|
{phase === 'review' && (
|
||||||
|
<div>
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<span className="font-medium">Found: <span className="text-green-700">{foundCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Up to date: <span className="text-gray-600">{noChangeCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Changes: <span className="text-blue-700">{foundCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Not in NVD: <span className="text-gray-400">{notFoundCount}</span></span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>Errors: <span className="text-red-600">{errorCount}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk controls */}
|
||||||
|
{Object.keys(descriptionChoices).length > 0 && (
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<span className="text-sm text-gray-600 self-center">Descriptions:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkDescriptionChoice('keep')}
|
||||||
|
className="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
Keep All Existing
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkDescriptionChoice('nvd')}
|
||||||
|
className="px-3 py-1 text-xs rounded border border-green-300 text-green-700 hover:bg-green-50 transition-colors"
|
||||||
|
>
|
||||||
|
Use All NVD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparison table */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(results).map(([cveId, r]) => {
|
||||||
|
if (r.status === 'no_change') {
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
<span className="font-mono font-medium text-gray-500">{cveId}</span>
|
||||||
|
<span className="text-gray-400">No changes needed</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.status === 'not_found') {
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
<span className="font-mono font-medium text-gray-400">{cveId}</span>
|
||||||
|
<span className="text-gray-400 italic">Not found in NVD</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.status === 'error') {
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="flex items-center gap-3 p-3 bg-red-50 rounded-lg text-sm">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||||
|
<span className="font-mono font-medium text-gray-700">{cveId}</span>
|
||||||
|
<span className="text-red-600">{r.error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// status === 'found' — show changes
|
||||||
|
const isExpanded = expandedDesc[cveId];
|
||||||
|
return (
|
||||||
|
<div key={cveId} className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mt-1 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||||
|
<span className="font-mono font-bold text-gray-900">{cveId}</span>
|
||||||
|
{r.sevChanged && (
|
||||||
|
<span className="text-xs">
|
||||||
|
Severity: <span className="text-red-600">{r.current.severity}</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="text-green-700">{r.nvd.severity}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.dateChanged && (
|
||||||
|
<span className="text-xs">
|
||||||
|
Date: <span className="text-red-600">{r.current.published_date || '(none)'}</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="text-green-700">{r.nvd.published_date}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.descChanged && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-600">Description:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedDesc(prev => ({ ...prev, [cveId]: !prev[cveId] }))}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
{isExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="p-2 bg-red-50 rounded border border-red-200">
|
||||||
|
<span className="font-medium text-red-700">Current: </span>
|
||||||
|
<span className="text-gray-700">{r.current.description || '(empty)'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 bg-green-50 rounded border border-green-200">
|
||||||
|
<span className="font-medium text-green-700">NVD: </span>
|
||||||
|
<span className="text-gray-700">{r.nvd.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">{truncate(r.nvd.description)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description choice */}
|
||||||
|
<div className="flex gap-4 mt-2">
|
||||||
|
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`desc-${cveId}`}
|
||||||
|
checked={descriptionChoices[cveId] === 'keep'}
|
||||||
|
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'keep' }))}
|
||||||
|
className="text-blue-600"
|
||||||
|
/>
|
||||||
|
Keep existing
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`desc-${cveId}`}
|
||||||
|
checked={descriptionChoices[cveId] === 'nvd'}
|
||||||
|
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'nvd' }))}
|
||||||
|
className="text-green-600"
|
||||||
|
/>
|
||||||
|
Use NVD
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Applying Phase */}
|
||||||
|
{phase === 'applying' && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Loader className="w-10 h-10 text-green-600 animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-gray-700">Applying changes...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Done Phase */}
|
||||||
|
{phase === 'done' && applyResult && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
{applyResult.error ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-red-700 font-medium mb-2">Sync failed</p>
|
||||||
|
<p className="text-sm text-gray-600">{applyResult.error}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-green-700 font-medium mb-2">Sync complete</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated
|
||||||
|
</p>
|
||||||
|
{applyResult.errors && applyResult.errors.length > 0 && (
|
||||||
|
<p className="text-sm text-amber-600 mt-2">
|
||||||
|
{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-6 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||||
|
{phase === 'review' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={applyChanges}
|
||||||
|
disabled={getChangesCount() === 0}
|
||||||
|
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply {getChangesCount()} Change{getChangesCount() !== 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === 'done' && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { User, LogOut, ChevronDown, Shield } from 'lucide-react';
|
import { User, LogOut, ChevronDown, Shield, Clock } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
export default function UserMenu({ onManageUsers }) {
|
export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||||
const { user, logout, isAdmin } = useAuth();
|
const { user, logout, isAdmin } = useAuth();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
@@ -42,6 +42,13 @@ export default function UserMenu({ onManageUsers }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAuditLog = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (onAuditLog) {
|
||||||
|
onAuditLog();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,13 +78,22 @@ export default function UserMenu({ onManageUsers }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<button
|
<>
|
||||||
onClick={handleManageUsers}
|
<button
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-3"
|
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
|
<Shield className="w-4 h-4" />
|
||||||
</button>
|
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
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user