From 1a578b23c1f34e5393e6a0652191cbf937ff7cd5 Mon Sep 17 00:00:00 2001 From: jramos Date: Thu, 29 Jan 2026 15:10:29 -0700 Subject: [PATCH] Audit logging feature files --- TEST_PLAN_AUDIT_LOG.md | 222 ++++++++++++++++++++ backend/helpers/auditLog.js | 21 ++ backend/migrate-audit-log.js | 96 +++++++++ backend/routes/auditLog.js | 114 +++++++++++ backend/routes/auth.js | 62 +++++- backend/routes/users.js | 47 ++++- backend/server.js | 56 ++++- backend/setup.js | 20 +- frontend/src/App.js | 9 +- frontend/src/components/AuditLog.js | 304 ++++++++++++++++++++++++++++ frontend/src/components/UserMenu.js | 34 +++- 11 files changed, 964 insertions(+), 21 deletions(-) create mode 100644 TEST_PLAN_AUDIT_LOG.md create mode 100644 backend/helpers/auditLog.js create mode 100644 backend/migrate-audit-log.js create mode 100644 backend/routes/auditLog.js create mode 100644 frontend/src/components/AuditLog.js diff --git a/TEST_PLAN_AUDIT_LOG.md b/TEST_PLAN_AUDIT_LOG.md new file mode 100644 index 0000000..abd21ad --- /dev/null +++ b/TEST_PLAN_AUDIT_LOG.md @@ -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_.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** | | diff --git a/backend/helpers/auditLog.js b/backend/helpers/auditLog.js new file mode 100644 index 0000000..e951228 --- /dev/null +++ b/backend/helpers/auditLog.js @@ -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; diff --git a/backend/migrate-audit-log.js b/backend/migrate-audit-log.js new file mode 100644 index 0000000..799d734 --- /dev/null +++ b/backend/migrate-audit-log.js @@ -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(); diff --git a/backend/routes/auditLog.js b/backend/routes/auditLog.js new file mode 100644 index 0000000..9a81c2c --- /dev/null +++ b/backend/routes/auditLog.js @@ -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; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 4b5e584..dc383d6 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -3,7 +3,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); -function createAuthRouter(db) { +function createAuthRouter(db, logAudit) { const router = express.Router(); // Login @@ -28,16 +28,43 @@ function createAuthRouter(db) { }); if (!user) { + logAudit(db, { + userId: null, + username: username, + action: 'login_failed', + entityType: 'auth', + entityId: null, + details: { reason: 'user_not_found' }, + ipAddress: req.ip + }); return res.status(401).json({ error: 'Invalid username or password' }); } if (!user.is_active) { + logAudit(db, { + userId: user.id, + username: username, + action: 'login_failed', + entityType: 'auth', + entityId: null, + details: { reason: 'account_disabled' }, + ipAddress: req.ip + }); return res.status(401).json({ error: 'Account is disabled' }); } // Verify password const validPassword = await bcrypt.compare(password, user.password_hash); if (!validPassword) { + logAudit(db, { + userId: user.id, + username: username, + action: 'login_failed', + entityType: 'auth', + entityId: null, + details: { reason: 'invalid_password' }, + ipAddress: req.ip + }); return res.status(401).json({ error: 'Invalid username or password' }); } @@ -77,6 +104,16 @@ function createAuthRouter(db) { maxAge: 24 * 60 * 60 * 1000 // 24 hours }); + logAudit(db, { + userId: user.id, + username: user.username, + action: 'login', + entityType: 'auth', + entityId: null, + details: { role: user.role }, + ipAddress: req.ip + }); + res.json({ message: 'Login successful', user: { @@ -97,6 +134,17 @@ function createAuthRouter(db) { const sessionId = req.cookies?.session_id; if (sessionId) { + // Look up user before deleting session + const session = await new Promise((resolve) => { + db.get( + `SELECT u.id as user_id, u.username FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.session_id = ?`, + [sessionId], + (err, row) => resolve(row || null) + ); + }); + // Delete session from database await new Promise((resolve) => { db.run( @@ -105,6 +153,18 @@ function createAuthRouter(db) { () => 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 diff --git a/backend/routes/users.js b/backend/routes/users.js index db2bb6f..bee34f5 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -2,7 +2,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); -function createUsersRouter(db, requireAuth, requireRole) { +function createUsersRouter(db, requireAuth, requireRole, logAudit) { const router = express.Router(); // 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({ message: 'User created successfully', 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 (is_active === false) { await new Promise((resolve) => { @@ -187,6 +214,14 @@ function createUsersRouter(db, requireAuth, requireRole) { } try { + // Look up the user before deleting + const targetUser = await new Promise((resolve, reject) => { + db.get('SELECT username FROM users WHERE id = ?', [userId], (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); + // Delete sessions first (foreign key) await new Promise((resolve) => { db.run('DELETE FROM sessions WHERE user_id = ?', [userId], () => resolve()); @@ -204,6 +239,16 @@ function createUsersRouter(db, requireAuth, requireRole) { return res.status(404).json({ error: 'User not found' }); } + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'user_delete', + entityType: 'user', + entityId: String(userId), + details: { deleted_username: targetUser ? targetUser.username : 'unknown' }, + ipAddress: req.ip + }); + res.json({ message: 'User deleted successfully' }); } catch (err) { console.error('Delete user error:', err); diff --git a/backend/server.js b/backend/server.js index 5d27b6b..3246bad 100644 --- a/backend/server.js +++ b/backend/server.js @@ -15,6 +15,8 @@ const fs = require('fs'); const { requireAuth, requireRole } = require('./middleware/auth'); const createAuthRouter = require('./routes/auth'); const createUsersRouter = require('./routes/users'); +const createAuditLogRouter = require('./routes/auditLog'); +const logAudit = require('./helpers/auditLog'); const app = express(); const PORT = process.env.PORT || 3001; @@ -46,10 +48,13 @@ const db = new sqlite3.Database('./cve_database.db', (err) => { }); // Auth routes (public) -app.use('/api/auth', createAuthRouter(db)); +app.use('/api/auth', createAuthRouter(db, logAudit)); // 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)); // Simple storage - upload to temp directory first const storage = multer.diskStorage({ @@ -215,10 +220,19 @@ app.post('/api/cves', requireAuth(db), requireRole('editor', 'admin'), (req, res } return res.status(500).json({ error: err.message }); } - res.json({ - id: this.lastID, + logAudit(db, { + userId: req.user.id, + username: req.user.username, + action: 'cve_create', + entityType: 'cve', + entityId: cve_id, + details: { vendor, severity }, + ipAddress: req.ip + }); + res.json({ + id: this.lastID, cve_id, - message: `CVE created successfully for vendor: ${vendor}` + message: `CVE created successfully for vendor: ${vendor}` }); }); }); @@ -230,12 +244,20 @@ app.patch('/api/cves/:cveId/status', requireAuth(db), requireRole('editor', 'adm const { status } = req.body; const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`; - - db.run(query, [ - vendor,status, cveId], function(err) { + + db.run(query, [status, cveId], function(err) { if (err) { return res.status(500).json({ error: err.message }); } + 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 }); }); }); @@ -329,6 +351,15 @@ app.post('/api/cves/:cveId/documents', requireAuth(db), requireRole('editor', 'a } 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({ id: this.lastID, message: 'Document uploaded successfully', @@ -359,6 +390,15 @@ app.delete('/api/documents/:id', requireAuth(db), requireRole('admin'), (req, re if (err) { 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' }); }); }); diff --git a/backend/setup.js b/backend/setup.js index 504bbd7..c4b73ed 100755 --- a/backend/setup.js +++ b/backend/setup.js @@ -88,6 +88,24 @@ function initializeDatabase() { CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + -- Audit log table for tracking user actions + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username VARCHAR(50) NOT NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id VARCHAR(100), + details TEXT, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_audit_user_id ON audit_logs(user_id); + CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action); + CREATE INDEX IF NOT EXISTS idx_audit_entity_type ON audit_logs(entity_type); + CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at); + INSERT OR IGNORE INTO required_documents (vendor, document_type, is_mandatory, description) VALUES ('Microsoft', 'advisory', 1, 'Official Microsoft Security Advisory'), ('Microsoft', 'screenshot', 0, 'Proof of patch application'), @@ -244,7 +262,7 @@ function displaySummary() { console.log('╚════════════════════════════════════════════════════════╝'); console.log('\n📊 What was created:'); 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(' ✓ Vendor column in documents table'); console.log(' ✓ User authentication with session-based auth'); diff --git a/frontend/src/App.js b/frontend/src/App.js index 43c4f91..b7a9f3e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,6 +4,7 @@ import { useAuth } from './contexts/AuthContext'; import LoginForm from './components/LoginForm'; import UserMenu from './components/UserMenu'; import UserManagement from './components/UserManagement'; +import AuditLog from './components/AuditLog'; 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'; @@ -27,6 +28,7 @@ export default function App() { const [quickCheckResult, setQuickCheckResult] = useState(null); const [showAddCVE, setShowAddCVE] = useState(false); const [showUserManagement, setShowUserManagement] = useState(false); + const [showAuditLog, setShowAuditLog] = useState(false); const [newCVE, setNewCVE] = useState({ cve_id: '', vendor: '', @@ -304,7 +306,7 @@ export default function App() { Add CVE/Vendor )} - setShowUserManagement(true)} /> + setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} /> @@ -313,6 +315,11 @@ export default function App() { setShowUserManagement(false)} /> )} + {/* Audit Log Modal */} + {showAuditLog && ( + setShowAuditLog(false)} /> + )} + {/* Add CVE Modal */} {showAddCVE && (
diff --git a/frontend/src/components/AuditLog.js b/frontend/src/components/AuditLog.js new file mode 100644 index 0000000..4f8f084 --- /dev/null +++ b/frontend/src/components/AuditLog.js @@ -0,0 +1,304 @@ +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' }, +}; + +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 ( + + {action} + + ); + }; + + const handleFilter = (e) => { + e.preventDefault(); + fetchLogs(1); + }; + + const handleReset = () => { + setUserFilter(''); + setActionFilter(''); + setEntityTypeFilter(''); + setStartDate(''); + setEndDate(''); + }; + + return ( +
+
+ {/* Header */} +
+
+

Audit Log

+

Track all user actions across the system

+
+ +
+ + {/* Filter Bar */} +
+
+
+ +
+ + 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" + /> +
+
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + +
+
+ + {/* Content */} +
+ {loading ? ( +
+ +

Loading audit logs...

+
+ ) : error ? ( +
+ +

{error}

+
+ ) : logs.length === 0 ? ( +
+

No audit log entries found.

+
+ ) : ( +
+ + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + ))} + +
TimeUserActionEntityDetailsIP Address
+ {formatDate(log.created_at)} + + {log.username} + + {getActionBadge(log.action)} + + {log.entity_type} + {log.entity_id && ( + {log.entity_id} + )} + + {formatDetails(log.details)} + + {log.ip_address || '-'} +
+
+ )} +
+ + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+

+ Showing {((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} entries +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/UserMenu.js b/frontend/src/components/UserMenu.js index aa9d365..d351bfc 100644 --- a/frontend/src/components/UserMenu.js +++ b/frontend/src/components/UserMenu.js @@ -1,8 +1,8 @@ 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'; -export default function UserMenu({ onManageUsers }) { +export default function UserMenu({ onManageUsers, onAuditLog }) { const { user, logout, isAdmin } = useAuth(); const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); @@ -42,6 +42,13 @@ export default function UserMenu({ onManageUsers }) { } }; + const handleAuditLog = () => { + setIsOpen(false); + if (onAuditLog) { + onAuditLog(); + } + }; + if (!user) return null; return ( @@ -71,13 +78,22 @@ export default function UserMenu({ onManageUsers }) {
{isAdmin() && ( - + <> + + + )}