// CVE Management Backend API // Install: npm install express pg multer cors dotenv bcryptjs cookie-parser // Force IPv4-first DNS resolution globally — must be set before any network modules load. // card.charter.com has IPv6 AAAA records that are unreachable from this network. require('dns').setDefaultResultOrder('ipv4first'); require('dotenv').config(); const express = require('express'); const multer = require('multer'); const cors = require('cors'); const cookieParser = require('cookie-parser'); const path = require('path'); const fs = require('fs'); // PostgreSQL connection pool const pool = require('./db'); // Auth imports const { requireAuth, requireGroup } = require('./middleware/auth'); const createAuthRouter = require('./routes/auth'); const createUsersRouter = require('./routes/users'); const createAuditLogRouter = require('./routes/auditLog'); const logAudit = require('./helpers/auditLog'); const createNvdLookupRouter = require('./routes/nvdLookup'); const createKnowledgeBaseRouter = require('./routes/knowledgeBase'); const createArcherTicketsRouter = require('./routes/archerTickets'); const createArcherTemplatesRouter = require('./routes/archerTemplates'); const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows'); const createIvantiFindingsRouter = require('./routes/ivantiFindings'); const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue'); const createIvantiArchiveRouter = require('./routes/ivantiArchive'); const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow'); const { createComplianceRouter } = require('./routes/compliance'); const { createVCLMultiVerticalRouter } = require('./routes/vclMultiVertical'); const createAtlasRouter = require('./routes/atlas'); const createJiraTicketsRouter = require('./routes/jiraTickets'); const createCardApiRouter = require('./routes/cardApi'); const createFeedbackRouter = require('./routes/feedback'); const createWebhooksRouter = require('./routes/webhooks'); const createNotificationsRouter = require('./routes/notifications'); const app = express(); const PORT = process.env.PORT || 3001; const API_HOST = process.env.API_HOST || 'localhost'; const SESSION_SECRET = process.env.SESSION_SECRET; if (!SESSION_SECRET) { console.error('FATAL: SESSION_SECRET environment variable must be set'); process.exit(1); } const CORS_ORIGINS = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',') : ['http://localhost:3000']; // ========== SECURITY HELPERS ========== // Allowed file extensions for document uploads (documents only, no executables) const ALLOWED_EXTENSIONS = new Set([ '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.txt', '.md', '.csv', '.log', '.msg', '.eml', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.odt', '.ods', '.odp', '.rtf', '.html', '.htm', '.xml', '.json', '.yaml', '.yml', '.zip', '.gz', '.tar', '.7z' ]); // Allowed MIME type prefixes const ALLOWED_MIME_PREFIXES = [ 'image/', 'text/', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats', 'application/vnd.ms-', 'application/vnd.oasis.opendocument', 'application/rtf', 'application/json', 'application/xml', 'application/vnd.ms-outlook', 'message/rfc822', 'application/zip', 'application/gzip', 'application/x-7z', 'application/x-tar', 'application/octet-stream' ]; // Sanitize a single path segment (cveId, vendor, filename) to prevent traversal function sanitizePathSegment(segment) { if (!segment || typeof segment !== 'string') return ''; // Remove path separators, null bytes, and .. sequences return segment .replace(/\0/g, '') .replace(/\.\./g, '') .replace(/[\/\\]/g, '') .trim(); } // Validate that a resolved path is within the uploads directory function isPathWithinUploads(targetPath) { const uploadsRoot = path.resolve('uploads'); const resolved = path.resolve(targetPath); return resolved.startsWith(uploadsRoot + path.sep) || resolved === uploadsRoot; } // Validate CVE ID format const CVE_ID_PATTERN = /^CVE-\d{4}-\d{4,}$/; function isValidCveId(cveId) { return typeof cveId === 'string' && CVE_ID_PATTERN.test(cveId); } // Allowed enum values const VALID_SEVERITIES = ['Critical', 'High', 'Medium', 'Low']; const VALID_STATUSES = ['Open', 'Addressed', 'In Progress', 'Resolved']; const VALID_DOC_TYPES = ['advisory', 'email', 'screenshot', 'patch', 'other']; const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed']; // Validate vendor name - printable chars, reasonable length function isValidVendor(vendor) { return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200; } // Log all incoming requests app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`); next(); }); // Security headers app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin res.setHeader('X-XSS-Protection', '1; mode=block'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); next(); }); // Middleware app.use(cors({ origin: CORS_ORIGINS, credentials: true })); // Only parse JSON for requests with application/json content type app.use(express.json({ limit: '1mb', type: 'application/json' })); app.use(cookieParser()); app.use('/uploads', express.static('uploads', { dotfiles: 'deny', index: false })); // Health check endpoint (public — used by CI/CD pipeline verification) app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Auth routes (public) app.use('/api/auth', createAuthRouter(logAudit)); // User management routes (admin only) app.use('/api/users', createUsersRouter(requireAuth, requireGroup, logAudit)); // Audit log routes (admin only) app.use('/api/audit-logs', createAuditLogRouter()); // NVD lookup routes (authenticated users) app.use('/api/nvd', createNvdLookupRouter()); // Simple storage - upload to temp directory first const storage = multer.diskStorage({ destination: (req, file, cb) => { const tempDir = 'uploads/temp'; if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } cb(null, tempDir); }, filename: (req, file, cb) => { const timestamp = Date.now(); // Sanitize original filename - strip path components and dangerous chars const safeName = sanitizePathSegment(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); cb(null, `${timestamp}-${safeName}`); } }); // File filter - reject executables and non-allowed types function fileFilter(req, file, cb) { const ext = path.extname(file.originalname).toLowerCase(); if (!ALLOWED_EXTENSIONS.has(ext)) { return cb(new Error(`File type '${ext}' is not allowed. Allowed types: ${[...ALLOWED_EXTENSIONS].join(', ')}`)); } const mimeAllowed = ALLOWED_MIME_PREFIXES.some(prefix => file.mimetype.startsWith(prefix)); if (!mimeAllowed) { return cb(new Error(`MIME type '${file.mimetype}' is not allowed.`)); } cb(null, true); } const upload = multer({ storage: storage, fileFilter: fileFilter, limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit }); // Knowledge base routes (editor/admin for upload/delete, all authenticated for view) app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload)); // Archer tickets routes (editor/admin for create/update/delete, all authenticated for view) app.use('/api/archer-tickets', createArcherTicketsRouter()); // Archer template library routes (editor/admin for create/update/delete/clone, all authenticated for view) app.use('/api/archer-templates', createArcherTemplatesRouter()); // Ivanti / RiskSense workflow routes (all authenticated users) app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter()); // Ivanti / RiskSense host findings routes (all authenticated users) // Pool imported directly inside the module; first arg kept for signature compat app.use('/api/ivanti/findings', createIvantiFindingsRouter(pool, requireAuth)); // Ivanti queue routes — per-user staging queue for FP / Archer workflows app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter()); // Ivanti archive routes — finding archive tracking for severity score drift app.use('/api/ivanti/archive', createIvantiArchiveRouter()); // Ivanti FP workflow routes — submit False Positive workflows to Ivanti API app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter()); // VCL multi-vertical routes — cross-organizational compliance reporting // Must be mounted BEFORE the general compliance router since both share the /api/compliance prefix app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(upload)); // AEO compliance routes — xlsx upload, non-compliant item tracking, notes app.use('/api/compliance', createComplianceRouter(upload)); // Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges app.use('/api/atlas', createAtlasRouter()); // Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create) app.use('/api/jira-tickets', createJiraTicketsRouter()); // CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search app.use('/api/card', createCardApiRouter()); // Feedback routes — bug reports and feature requests to GitLab app.use('/api/feedback', createFeedbackRouter()); // In-app notifications routes (authenticated users) app.use('/api/notifications', createNotificationsRouter()); // GitLab webhook routes — receives issue lifecycle events (no auth required) app.use('/api/webhooks', createWebhooksRouter()); // ========== CVE ENDPOINTS ========== // Get all CVEs with optional filters (authenticated users) app.get('/api/cves', requireAuth(), async (req, res) => { const { search, vendor, severity, status } = req.query; let query = ` SELECT c.*, COUNT(d.id) as document_count FROM cves c LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor WHERE 1=1 `; const params = []; let paramIndex = 1; if (search) { query += ` AND (c.cve_id ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex + 1})`; params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } if (vendor && vendor !== 'All Vendors') { query += ` AND c.vendor = $${paramIndex++}`; params.push(vendor); } if (severity && severity !== 'All Severities') { query += ` AND c.severity = $${paramIndex++}`; params.push(severity); } if (status) { query += ` AND c.status = $${paramIndex++}`; params.push(status); } query += ` GROUP BY c.id ORDER BY c.published_date DESC`; try { const { rows } = await pool.query(query, params); res.json(rows); } catch (err) { console.error('Error fetching CVEs:', err); res.status(500).json({ error: 'Internal server error.' }); } }); // Get distinct CVE IDs for NVD sync (authenticated users) app.get('/api/cves/distinct-ids', requireAuth(), async (req, res) => { try { const { rows } = await pool.query('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id'); res.json(rows.map(r => r.cve_id)); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users) app.get('/api/cves/check/:cveId', requireAuth(), async (req, res) => { const { cveId } = req.params; const query = ` SELECT c.*, COUNT(d.id) as total_documents, COUNT(CASE WHEN d.type = 'email' THEN 1 END) as has_email, COUNT(CASE WHEN d.type = 'screenshot' THEN 1 END) as has_screenshot FROM cves c LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor WHERE c.cve_id = $1 GROUP BY c.id `; try { const { rows } = await pool.query(query, [cveId]); if (!rows || rows.length === 0) { return res.json({ exists: false, message: 'CVE not found - not yet addressed' }); } // Return all vendor entries for this CVE res.json({ exists: true, vendors: rows.map(row => ({ vendor: row.vendor, severity: row.severity, status: row.status, total_documents: parseInt(row.total_documents), doc_types: { email: parseInt(row.has_email) > 0, screenshot: parseInt(row.has_screenshot) > 0 } })), addressed: true }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // NEW ENDPOINT: Get all vendors for a specific CVE (authenticated users) app.get('/api/cves/:cveId/vendors', requireAuth(), async (req, res) => { const { cveId } = req.params; try { const { rows } = await pool.query( `SELECT vendor, severity, status, description, published_date FROM cves WHERE cve_id = $1 ORDER BY vendor`, [cveId] ); res.json(rows); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Get tooltip data for a specific CVE (authenticated users) app.get('/api/cves/:cveId/tooltip', requireAuth(), async (req, res) => { const { cveId } = req.params; if (!CVE_ID_PATTERN.test(cveId)) { return res.status(400).json({ error: 'Invalid CVE ID format.' }); } try { const { rows } = await pool.query( 'SELECT cve_id, description, severity FROM cves WHERE cve_id = $1 LIMIT 1', [cveId] ); const row = rows[0]; if (!row) { return res.json({ exists: false }); } let description = row.description || ''; if (description.length > 300) { description = description.substring(0, 300) + '\u2026'; } res.json({ exists: true, cve_id: row.cve_id, description, severity: row.severity }); } catch (err) { console.error('Error fetching CVE tooltip:', err); res.status(500).json({ error: 'Internal server error.' }); } }); // Compliance export — reads from cve_document_status view app.get('/api/cves/compliance', requireAuth(), async (req, res) => { try { const { rows } = await pool.query('SELECT * FROM cve_document_status ORDER BY cve_id, vendor'); res.json(rows); } catch (err) { console.error('Error fetching compliance data:', err); res.status(500).json({ error: 'Internal server error.' }); } }); // Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin) app.post('/api/cves', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cve_id, vendor, severity, description, published_date } = req.body; // Input validation if (!cve_id || !isValidCveId(cve_id)) { return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' }); } if (!vendor || !isValidVendor(vendor)) { return res.status(400).json({ error: 'Vendor is required and must be under 200 characters.' }); } if (!severity || !VALID_SEVERITIES.includes(severity)) { return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` }); } if (!description || typeof description !== 'string' || description.length > 10000) { return res.status(400).json({ error: 'Description is required and must be under 10000 characters.' }); } if (!published_date || !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) { return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' }); } try { const { rows } = await pool.query( `INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [cve_id, vendor, severity, description, published_date, req.user.id] ); logAudit({ 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: rows[0].id, cve_id, message: `CVE created successfully for vendor: ${vendor}` }); } catch (err) { console.error('DATABASE ERROR:', err); if (err.code === '23505') { // Postgres unique violation return res.status(409).json({ error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.' }); } res.status(500).json({ error: 'Failed to create CVE entry.' }); } }); // Update CVE status (editor or admin) app.patch('/api/cves/:cveId/status', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cveId } = req.params; const { status } = req.body; if (!status || !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` }); } try { const result = await pool.query( 'UPDATE cves SET status = $1, updated_at = NOW() WHERE cve_id = $2', [status, cveId] ); logAudit({ 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: result.rowCount }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Bulk sync CVE data from NVD (editor or admin) app.post('/api/cves/nvd-sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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 = []; for (const entry of updates) { const fields = []; const values = []; let paramIndex = 1; if (entry.description !== null && entry.description !== undefined) { fields.push(`description = $${paramIndex++}`); values.push(entry.description); } if (entry.severity !== null && entry.severity !== undefined) { fields.push(`severity = $${paramIndex++}`); values.push(entry.severity); } if (entry.published_date !== null && entry.published_date !== undefined) { fields.push(`published_date = $${paramIndex++}`); values.push(entry.published_date); } if (fields.length === 0) continue; fields.push('updated_at = NOW()'); values.push(entry.cve_id); try { const result = await pool.query( `UPDATE cves SET ${fields.join(', ')} WHERE cve_id = $${paramIndex}`, values ); updated += result.rowCount; } catch (err) { console.error('NVD sync update error:', err); errors.push({ cve_id: entry.cve_id, error: 'Update failed' }); } } logAudit({ userId: req.user.id, username: req.user.username, action: 'cve_nvd_sync', entityType: 'cve', entityId: null, details: { count: updated, cve_ids: updates.map(u => u.cve_id) }, ipAddress: req.ip }); const result = { message: 'NVD sync completed', updated }; if (errors.length > 0) result.errors = errors; res.json(result); }); // ========== CVE EDIT & DELETE ENDPOINTS ========== // Edit single CVE entry (editor or admin) app.put('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; const { cve_id, vendor, severity, description, published_date, status } = req.body; // Input validation for provided fields if (cve_id !== undefined && !isValidCveId(cve_id)) { return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' }); } if (vendor !== undefined && !isValidVendor(vendor)) { return res.status(400).json({ error: 'Vendor must be under 200 characters.' }); } if (severity !== undefined && !VALID_SEVERITIES.includes(severity)) { return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` }); } if (description !== undefined && (typeof description !== 'string' || description.length > 10000)) { return res.status(400).json({ error: 'Description must be under 10000 characters.' }); } if (published_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) { return res.status(400).json({ error: 'Published date must be in YYYY-MM-DD format.' }); } if (status !== undefined && !VALID_STATUSES.includes(status)) { return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` }); } try { // Fetch existing row first const { rows: existingRows } = await pool.query('SELECT * FROM cves WHERE id = $1', [id]); const existing = existingRows[0]; if (!existing) return res.status(404).json({ error: 'CVE entry not found' }); const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status }; const newCveId = cve_id !== undefined ? cve_id : existing.cve_id; const newVendor = vendor !== undefined ? vendor : existing.vendor; const cveIdChanged = newCveId !== existing.cve_id; const vendorChanged = newVendor !== existing.vendor; if (cveIdChanged || vendorChanged) { // Check UNIQUE constraint const { rows: conflictRows } = await pool.query( 'SELECT id FROM cves WHERE cve_id = $1 AND vendor = $2 AND id != $3', [newCveId, newVendor, id] ); if (conflictRows.length > 0) { return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' }); } // Rename document directory (with path traversal prevention) const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor)); const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor)); if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) { return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' }); } if (fs.existsSync(oldDir)) { const newParent = path.join('uploads', newCveId); if (!fs.existsSync(newParent)) { fs.mkdirSync(newParent, { recursive: true }); } fs.renameSync(oldDir, newDir); // Clean up old cve_id directory if empty const oldParent = path.join('uploads', existing.cve_id); if (fs.existsSync(oldParent)) { const remaining = fs.readdirSync(oldParent); if (remaining.length === 0) fs.rmdirSync(oldParent); } } // Update documents table - file paths const { rows: docs } = await pool.query( 'SELECT id, file_path FROM documents WHERE cve_id = $1 AND vendor = $2', [existing.cve_id, existing.vendor] ); const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor); const newPrefix = path.join('uploads', newCveId, newVendor); for (const doc of docs) { const newFilePath = doc.file_path.replace(oldPrefix, newPrefix); await pool.query( 'UPDATE documents SET cve_id = $1, vendor = $2, file_path = $3 WHERE id = $4', [newCveId, newVendor, newFilePath, doc.id] ); } } // Build dynamic SET clause const fields = []; const values = []; let paramIndex = 1; if (cve_id !== undefined) { fields.push(`cve_id = $${paramIndex++}`); values.push(cve_id); } if (vendor !== undefined) { fields.push(`vendor = $${paramIndex++}`); values.push(vendor); } if (severity !== undefined) { fields.push(`severity = $${paramIndex++}`); values.push(severity); } if (description !== undefined) { fields.push(`description = $${paramIndex++}`); values.push(description); } if (published_date !== undefined) { fields.push(`published_date = $${paramIndex++}`); values.push(published_date); } if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); } if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); fields.push('updated_at = NOW()'); values.push(id); const updateResult = await pool.query( `UPDATE cves SET ${fields.join(', ')} WHERE id = $${paramIndex}`, values ); const after = { cve_id: newCveId, vendor: newVendor, severity: severity !== undefined ? severity : existing.severity, description: description !== undefined ? description : existing.description, published_date: published_date !== undefined ? published_date : existing.published_date, status: status !== undefined ? status : existing.status }; logAudit({ userId: req.user.id, username: req.user.username, action: 'cve_edit', entityType: 'cve', entityId: newCveId, details: { before, after }, ipAddress: req.ip }); res.json({ message: 'CVE updated successfully', changes: updateResult.rowCount }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route app.delete('/api/cves/by-cve-id/:cveId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { cveId } = req.params; try { // Get all rows for this CVE ID to know what we're deleting const { rows } = await pool.query('SELECT * FROM cves WHERE cve_id = $1', [cveId]); if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' }); // Ownership check: Standard_User can only delete CVEs they created if (req.user.group === 'Standard_User') { const notOwned = rows.some(row => row.created_by !== req.user.id); if (notOwned) { return res.status(403).json({ error: 'You can only delete resources you created' }); } // Cascade impact check for Standard_User const { rows: archerTickets } = await pool.query( 'SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = $1', [cveId] ); let jiraTickets = []; try { const jiraResult = await pool.query( 'SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = $1', [cveId] ); jiraTickets = jiraResult.rows; } catch (jiraErr) { // If table doesn't exist yet, treat as empty if (!jiraErr.message.includes('does not exist')) throw jiraErr; } const { rows: docs } = await pool.query( 'SELECT id, name, type FROM documents WHERE cve_id = $1', [cveId] ); const allTickets = [ ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) ]; // If no tickets at all, no compliance linkage possible — return cascade info if (allTickets.length === 0) { return res.json({ cascade_impact: { archer_tickets: [], jira_tickets: [], documents: (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })), blocked: false, blocked_reason: null } }); } // Check compliance linkage for each ticket const likeConditions = []; const likeParams = []; let pIdx = 1; for (const t of allTickets) { likeConditions.push(`ci.extra_json LIKE $${pIdx++}`); likeParams.push(`%${t.key}%`); } // Also check if the CVE ID itself appears in compliance extra_json likeConditions.push(`ci.extra_json LIKE $${pIdx++}`); likeParams.push(`%${cveId}%`); let compLinks = []; try { const compResult = await pool.query( `SELECT ci.id, ci.extra_json, cu.report_date FROM compliance_items ci JOIN compliance_uploads cu ON ci.upload_id = cu.id WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`, likeParams ); compLinks = compResult.rows; } catch (compErr) { if (!compErr.message.includes('does not exist')) throw compErr; } // Determine which tickets are compliance-linked const linkedTicketKeys = new Set(); for (const cl of compLinks) { const json = cl.extra_json || ''; for (const t of allTickets) { if (json.includes(t.key)) { linkedTicketKeys.add(`${t.source}:${t.id}`); } } if (json.includes(cveId)) { for (const t of allTickets) { linkedTicketKeys.add(`${t.source}:${t.id}`); } } } const archerTicketsResult = (archerTickets || []).map(t => ({ id: t.id, exc_number: t.exc_number, compliance_linked: linkedTicketKeys.has(`archer:${t.id}`) })); const jiraTicketsResult = (jiraTickets || []).map(t => ({ id: t.id, ticket_key: t.ticket_key, compliance_linked: linkedTicketKeys.has(`jira:${t.id}`) })); const documentsResult = (docs || []).map(d => ({ id: d.id, name: d.name, type: d.type })); const hasComplianceLink = archerTicketsResult.some(t => t.compliance_linked) || jiraTicketsResult.some(t => t.compliance_linked); if (hasComplianceLink) { const blockedArcher = archerTicketsResult.find(t => t.compliance_linked); const blockedJira = jiraTicketsResult.find(t => t.compliance_linked); const blockedLabel = blockedArcher ? `Archer ticket ${blockedArcher.exc_number}` : `JIRA ticket ${blockedJira.ticket_key}`; return res.status(403).json({ error: 'CVE deletion blocked: associated ticket linked to compliance report', cascade_impact: { archer_tickets: archerTicketsResult, jira_tickets: jiraTicketsResult, documents: documentsResult, blocked: true, blocked_reason: `${blockedLabel} is linked to a compliance report` } }); } // Not blocked — return cascade impact for frontend warning return res.json({ cascade_impact: { archer_tickets: archerTicketsResult, jira_tickets: jiraTicketsResult, documents: documentsResult, blocked: false, blocked_reason: null } }); } // Admin flow: proceed directly with deletion await pool.query('DELETE FROM documents WHERE cve_id = $1', [cveId]); const deleteResult = await pool.query('DELETE FROM cves WHERE cve_id = $1', [cveId]); // Remove upload directory (with path traversal prevention) const safeCveId = sanitizePathSegment(cveId); const cveDir = path.join('uploads', safeCveId); if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) { fs.rmSync(cveDir, { recursive: true, force: true }); } logAudit({ userId: req.user.id, username: req.user.username, action: 'cve_delete', entityType: 'cve', entityId: cveId, details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length }, ipAddress: req.ip }); res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: deleteResult.rowCount }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Delete single CVE vendor entry (editor or admin) app.delete('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; try { const { rows: cveRows } = await pool.query('SELECT * FROM cves WHERE id = $1', [id]); const cve = cveRows[0]; if (!cve) return res.status(404).json({ error: 'CVE entry not found' }); // Ownership check: Standard_User can only delete CVEs they created if (req.user.group === 'Standard_User' && cve.created_by !== req.user.id) { return res.status(403).json({ error: 'You can only delete resources you created' }); } // Cascade/compliance check for Standard_User if (req.user.group === 'Standard_User') { const { rows: archerTickets } = await pool.query( 'SELECT id, exc_number FROM archer_tickets WHERE cve_id = $1 AND vendor = $2', [cve.cve_id, cve.vendor] ); let jiraTickets = []; try { const jiraResult = await pool.query( 'SELECT id, ticket_key FROM jira_tickets WHERE cve_id = $1 AND vendor = $2', [cve.cve_id, cve.vendor] ); jiraTickets = jiraResult.rows; } catch (jiraErr) { if (!jiraErr.message.includes('does not exist')) throw jiraErr; } const allTickets = [ ...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })), ...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key })) ]; if (allTickets.length > 0) { const likeConditions = allTickets.map((_, i) => `ci.extra_json LIKE $${i + 1}`); const likeParams = allTickets.map(t => `%${t.key}%`); let compLinks = []; try { const compResult = await pool.query( `SELECT ci.id, ci.extra_json FROM compliance_items ci JOIN compliance_uploads cu ON ci.upload_id = cu.id WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`, likeParams ); compLinks = compResult.rows; } catch (compErr) { if (!compErr.message.includes('does not exist')) throw compErr; } const hasLink = compLinks.some(cl => { const json = cl.extra_json || ''; return allTickets.some(t => json.includes(t.key)); }); if (hasLink) { return res.status(403).json({ error: 'CVE deletion blocked: associated ticket linked to compliance report', cascade_impact: { blocked: true, blocked_reason: 'Associated ticket is linked to a compliance report' } }); } } } // Proceed with deletion // Delete associated documents from DB and disk const { rows: docs } = await pool.query( 'SELECT id, file_path FROM documents WHERE cve_id = $1 AND vendor = $2', [cve.cve_id, cve.vendor] ); // Delete document files from disk (with path traversal prevention) if (docs && docs.length > 0) { docs.forEach(doc => { if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) { fs.unlinkSync(doc.file_path); } }); } // Delete documents from DB await pool.query('DELETE FROM documents WHERE cve_id = $1 AND vendor = $2', [cve.cve_id, cve.vendor]); // Delete CVE row const deleteResult = await pool.query('DELETE FROM cves WHERE id = $1', [id]); // Clean up directories (with path traversal prevention) const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor)); if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) { fs.rmSync(safeVendorDir, { recursive: true, force: true }); } const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id)); if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) { const remaining = fs.readdirSync(safeCveDir); if (remaining.length === 0) fs.rmdirSync(safeCveDir); } logAudit({ userId: req.user.id, username: req.user.username, action: 'cve_delete', entityType: 'cve', entityId: cve.cve_id, details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity }, ipAddress: req.ip }); res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // ========== DOCUMENT ENDPOINTS ========== // Get documents for a CVE - FILTER BY VENDOR (authenticated users) app.get('/api/cves/:cveId/documents', requireAuth(), async (req, res) => { const { cveId } = req.params; const { vendor } = req.query; // Optional vendor filter let query = 'SELECT * FROM documents WHERE cve_id = $1'; const params = [cveId]; let paramIndex = 2; if (vendor) { query += ` AND vendor = $${paramIndex++}`; params.push(vendor); } query += ' ORDER BY uploaded_at DESC'; try { const { rows } = await pool.query(query, params); res.json(rows); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin) app.post('/api/cves/:cveId/documents', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => { upload.single('file')(req, res, async (err) => { if (err) { console.error('Upload error:', err.message); if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) { return res.status(400).json({ error: err.message }); } if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File exceeds the 10MB size limit.' }); } return res.status(500).json({ error: 'File upload failed.' }); } const { cveId } = req.params; const { type, notes, vendor } = req.body; const file = req.file; if (!file) { console.error('ERROR: No file uploaded'); return res.status(400).json({ error: 'No file uploaded' }); } if (!vendor) { if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(400).json({ error: 'Vendor is required' }); } // Validate document type if (type && !VALID_DOC_TYPES.includes(type)) { if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(400).json({ error: `Invalid document type. Must be one of: ${VALID_DOC_TYPES.join(', ')}` }); } // Sanitize path segments to prevent directory traversal const safeCveId = sanitizePathSegment(cveId); const safeVendor = sanitizePathSegment(vendor); const safeFilename = sanitizePathSegment(file.filename); if (!safeCveId || !safeVendor || !safeFilename) { if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(400).json({ error: 'Invalid CVE ID, vendor, or filename.' }); } // Move file from temp to proper location const finalDir = path.join('uploads', safeCveId, safeVendor); const finalPath = path.join(finalDir, safeFilename); // Verify paths stay within uploads directory if (!isPathWithinUploads(finalDir) || !isPathWithinUploads(finalPath)) { if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(400).json({ error: 'Invalid file path.' }); } if (!fs.existsSync(finalDir)) { fs.mkdirSync(finalDir, { recursive: true }); } // Move file from temp to final location fs.renameSync(file.path, finalPath); const fileSizeKB = (file.size / 1024).toFixed(2) + ' KB'; try { const { rows } = await pool.query( `INSERT INTO documents (cve_id, vendor, name, type, file_path, file_size, mime_type, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, [cveId, vendor, file.originalname, type, finalPath, fileSizeKB, file.mimetype, notes] ); logAudit({ 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: rows[0].id, message: 'Document uploaded successfully', file: { name: file.originalname, size: fileSizeKB } }); } catch (dbErr) { console.error('Document insert error:', dbErr); // If database insert fails, delete the file if (fs.existsSync(finalPath)) { fs.unlinkSync(finalPath); } res.status(500).json({ error: 'Internal server error.' }); } }); }); // Delete document (admin only) app.delete('/api/documents/:id', requireAuth(), requireGroup('Admin'), async (req, res) => { const { id } = req.params; try { // First get the file path to delete the actual file const { rows } = await pool.query('SELECT file_path FROM documents WHERE id = $1', [id]); const row = rows[0]; // Only delete file if path is within uploads directory if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) { fs.unlinkSync(row.file_path); } await pool.query('DELETE FROM documents WHERE id = $1', [id]); logAudit({ 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' }); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // ========== UTILITY ENDPOINTS ========== // Get all vendors (authenticated users) app.get('/api/vendors', requireAuth(), async (req, res) => { try { const { rows } = await pool.query('SELECT DISTINCT vendor FROM cves ORDER BY vendor'); res.json(rows.map(r => r.vendor)); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Get statistics (authenticated users) app.get('/api/stats', requireAuth(), async (req, res) => { const query = ` SELECT COUNT(DISTINCT c.id) as total_cves, COUNT(DISTINCT CASE WHEN c.severity = 'Critical' THEN c.id END) as critical_count, COUNT(DISTINCT CASE WHEN c.status = 'Addressed' THEN c.id END) as addressed_count, COUNT(DISTINCT d.id) as total_documents, COUNT(DISTINCT CASE WHEN cd.compliance_status = 'Complete' THEN c.id END) as compliant_count FROM cves c LEFT JOIN documents d ON c.cve_id = d.cve_id LEFT JOIN cve_document_status cd ON c.cve_id = cd.cve_id `; try { const { rows } = await pool.query(query); res.json(rows[0]); } catch (err) { console.error(err); res.status(500).json({ error: 'Internal server error.' }); } }); // Serve frontend build (for testing/production — serves React SPA) const frontendBuild = path.join(__dirname, '..', 'frontend', 'build'); if (fs.existsSync(frontendBuild)) { app.use(express.static(frontendBuild)); // SPA fallback — serve index.html for any non-API route app.use((req, res, next) => { if (!req.path.startsWith('/api/') && !req.path.startsWith('/uploads/')) { res.sendFile(path.join(frontendBuild, 'index.html')); } else { next(); } }); } // Start server app.listen(PORT, () => { console.log(`CVE API server running on http://${API_HOST}:${PORT}`); console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`); });