The DNS ipv4first setting must be applied before any module loads the https/http modules. When set inside cardApi.js helper, it's too late — the https module has already cached DNS resolution behavior. Moving it to the very top of server.js ensures it takes effect globally for all outbound connections.
1200 lines
46 KiB
JavaScript
1200 lines
46 KiB
JavaScript
// 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 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());
|
|
|
|
// 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(', ')}`);
|
|
});
|