Integrate Atlas InfoSec API to manage compliance action plans directly from the ReportingPage. Users can view, create, and update action plans for host findings without switching to the Atlas web tool. Backend: - Add atlasApi.js helper with Basic Auth, TLS skip, GET/PUT/PATCH/POST - Add atlas_action_plans_cache migration for SQLite cache table - Add atlas.js router with sync, status, and proxy CRUD endpoints - Mount Atlas router at /api/atlas in server.js - Extract hostId from Ivanti host findings during sync Frontend: - Add AtlasBadge component (amber=needs plan, green=has plan) - Add AtlasSlideOutPanel with plan list, create form, edit capability - Separate active plans from inactive history in collapsible section - Custom dark-themed plan type dropdown - Optimistic local state shows pending plans immediately after creation - Atlas sync button on ReportingPage toolbar - Prepopulate finding ID in create form from clicked row Environment: - Add ATLAS_API_URL, ATLAS_API_USER, ATLAS_API_PASS, ATLAS_SKIP_TLS to .env.example
1421 lines
60 KiB
JavaScript
1421 lines
60 KiB
JavaScript
// CVE Management Backend API
|
|
// Install: npm install express sqlite3 multer cors dotenv bcryptjs cookie-parser
|
|
|
|
require('dotenv').config();
|
|
|
|
const express = require('express');
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
const multer = require('multer');
|
|
const cors = require('cors');
|
|
const cookieParser = require('cookie-parser');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
// Auth imports
|
|
const { requireAuth, 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 createAtlasRouter = require('./routes/atlas');
|
|
|
|
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
|
|
}));
|
|
|
|
// Database connection
|
|
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
|
if (err) {
|
|
console.error('Database connection error:', err);
|
|
return;
|
|
}
|
|
console.log('Connected to CVE database');
|
|
|
|
// Ensure ivanti_todo_queue table exists (idempotent migration)
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS ivanti_todo_queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
finding_id TEXT NOT NULL,
|
|
finding_title TEXT,
|
|
cves_json TEXT,
|
|
ip_address TEXT,
|
|
vendor TEXT NOT NULL,
|
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'complete')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
)
|
|
`, (err2) => {
|
|
if (err2) console.error('Failed to create ivanti_todo_queue table:', err2);
|
|
else db.run(
|
|
'CREATE INDEX IF NOT EXISTS idx_todo_queue_user ON ivanti_todo_queue(user_id, status)',
|
|
(err3) => { if (err3) console.error('Failed to create todo_queue index:', err3); }
|
|
);
|
|
});
|
|
});
|
|
|
|
// Auth routes (public)
|
|
app.use('/api/auth', createAuthRouter(db, logAudit));
|
|
|
|
// User management routes (admin only)
|
|
app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
|
|
|
|
// Audit log routes (admin only)
|
|
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
|
|
|
|
// NVD lookup routes (authenticated users)
|
|
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
|
|
|
// Simple storage - upload to temp directory first
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
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(db, upload));
|
|
|
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
|
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
|
|
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
|
|
|
// Ivanti / RiskSense host findings routes (all authenticated users)
|
|
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
|
|
|
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
|
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
|
|
|
// Ivanti archive routes — finding archive tracking for severity score drift
|
|
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
|
|
|
|
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));
|
|
|
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
|
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
|
|
|
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
|
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
|
|
|
// ========== CVE ENDPOINTS ==========
|
|
|
|
// Get all CVEs with optional filters (authenticated users)
|
|
app.get('/api/cves', requireAuth(db), (req, res) => {
|
|
const { search, vendor, severity, status } = req.query;
|
|
|
|
let query = `
|
|
SELECT c.*, COUNT(d.id) as document_count
|
|
FROM cves c
|
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
|
WHERE 1=1
|
|
`;
|
|
|
|
const params = [];
|
|
|
|
if (search) {
|
|
query += ` AND (c.cve_id LIKE ? OR c.description LIKE ?)`;
|
|
params.push(`%${search}%`, `%${search}%`);
|
|
}
|
|
if (vendor && vendor !== 'All Vendors') {
|
|
query += ` AND c.vendor = ?`;
|
|
params.push(vendor);
|
|
}
|
|
if (severity && severity !== 'All Severities') {
|
|
query += ` AND c.severity = ?`;
|
|
params.push(severity);
|
|
}
|
|
if (status) {
|
|
query += ` AND c.status = ?`;
|
|
params.push(status);
|
|
}
|
|
|
|
query += ` GROUP BY c.id ORDER BY c.published_date DESC`;
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching CVEs:', err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
// Get distinct CVE IDs for NVD sync (authenticated users)
|
|
app.get('/api/cves/distinct-ids', requireAuth(db), (req, res) => {
|
|
db.all('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id', [], (err, rows) => {
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
res.json(rows.map(r => r.cve_id));
|
|
});
|
|
});
|
|
|
|
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
|
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
|
const { cveId } = req.params;
|
|
|
|
const query = `
|
|
SELECT c.*,
|
|
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 = ?
|
|
GROUP BY c.id
|
|
`;
|
|
|
|
db.all(query, [cveId], (err, rows) => {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
if (!rows || rows.length === 0) {
|
|
return res.json({
|
|
exists: false,
|
|
message: 'CVE not found - not yet addressed'
|
|
});
|
|
}
|
|
|
|
// Return 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: row.total_documents,
|
|
doc_types: {
|
|
email: row.has_email > 0,
|
|
screenshot: row.has_screenshot > 0
|
|
}
|
|
})),
|
|
addressed: true
|
|
});
|
|
});
|
|
});
|
|
|
|
// NEW ENDPOINT: Get all vendors for a specific CVE (authenticated users)
|
|
app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
|
const { cveId } = req.params;
|
|
|
|
const query = `
|
|
SELECT vendor, severity, status, description, published_date
|
|
FROM cves
|
|
WHERE cve_id = ?
|
|
ORDER BY vendor
|
|
`;
|
|
|
|
db.all(query, [cveId], (err, rows) => {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
// Get tooltip data for a specific CVE (authenticated users)
|
|
app.get('/api/cves/:cveId/tooltip', requireAuth(db), (req, res) => {
|
|
const { cveId } = req.params;
|
|
|
|
if (!CVE_ID_PATTERN.test(cveId)) {
|
|
return res.status(400).json({ error: 'Invalid CVE ID format.' });
|
|
}
|
|
|
|
db.get('SELECT cve_id, description, severity FROM cves WHERE cve_id = ? LIMIT 1', [cveId], (err, row) => {
|
|
if (err) {
|
|
console.error('Error fetching CVE tooltip:', err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
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 });
|
|
});
|
|
});
|
|
|
|
// Compliance export — reads from cve_document_status view
|
|
app.get('/api/cves/compliance', requireAuth(db), (req, res) => {
|
|
db.all('SELECT * FROM cve_document_status ORDER BY cve_id, vendor', [], (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching compliance data:', err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
|
app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
|
|
|
// Input validation
|
|
if (!cve_id || !isValidCveId(cve_id)) {
|
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
|
}
|
|
if (!vendor || !isValidVendor(vendor)) {
|
|
return res.status(400).json({ error: 'Vendor is required and must be under 200 characters.' });
|
|
}
|
|
if (!severity || !VALID_SEVERITIES.includes(severity)) {
|
|
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
|
}
|
|
if (!description || typeof description !== 'string' || description.length > 10000) {
|
|
return res.status(400).json({ error: 'Description is required and must be under 10000 characters.' });
|
|
}
|
|
if (!published_date || !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
|
return res.status(400).json({ error: 'Published date is required in YYYY-MM-DD format.' });
|
|
}
|
|
|
|
const query = `
|
|
INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`;
|
|
|
|
db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
|
|
if (err) {
|
|
console.error('DATABASE ERROR:', err);
|
|
if (err.message.includes('UNIQUE constraint failed')) {
|
|
return res.status(409).json({
|
|
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
|
});
|
|
}
|
|
return res.status(500).json({ error: 'Failed to create CVE entry.' });
|
|
}
|
|
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}`
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
// Update CVE status (editor or admin)
|
|
app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { cveId } = req.params;
|
|
const { status } = req.body;
|
|
|
|
if (!status || !VALID_STATUSES.includes(status)) {
|
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
|
}
|
|
|
|
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
|
|
|
db.run(query, [status, cveId], function(err) {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'cve_update_status',
|
|
entityType: 'cve',
|
|
entityId: cveId,
|
|
details: { status },
|
|
ipAddress: req.ip
|
|
});
|
|
res.json({ message: 'Status updated successfully', changes: this.changes });
|
|
});
|
|
});
|
|
|
|
// Bulk sync CVE data from NVD (editor or admin)
|
|
app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { updates } = req.body;
|
|
if (!Array.isArray(updates) || updates.length === 0) {
|
|
return res.status(400).json({ error: 'No updates provided' });
|
|
}
|
|
|
|
let updated = 0;
|
|
const errors = [];
|
|
let completed = 0;
|
|
|
|
db.serialize(() => {
|
|
updates.forEach((entry) => {
|
|
const fields = [];
|
|
const values = [];
|
|
if (entry.description !== null && entry.description !== undefined) {
|
|
fields.push('description = ?');
|
|
values.push(entry.description);
|
|
}
|
|
if (entry.severity !== null && entry.severity !== undefined) {
|
|
fields.push('severity = ?');
|
|
values.push(entry.severity);
|
|
}
|
|
if (entry.published_date !== null && entry.published_date !== undefined) {
|
|
fields.push('published_date = ?');
|
|
values.push(entry.published_date);
|
|
}
|
|
if (fields.length === 0) {
|
|
completed++;
|
|
if (completed === updates.length) sendResponse();
|
|
return;
|
|
}
|
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
|
values.push(entry.cve_id);
|
|
|
|
db.run(
|
|
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = ?`,
|
|
values,
|
|
function(err) {
|
|
if (err) {
|
|
console.error('NVD sync update error:', err);
|
|
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
|
|
} else {
|
|
updated += this.changes;
|
|
}
|
|
completed++;
|
|
if (completed === updates.length) sendResponse();
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
function sendResponse() {
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'cve_nvd_sync',
|
|
entityType: 'cve',
|
|
entityId: null,
|
|
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
|
ipAddress: req.ip
|
|
});
|
|
const result = { message: 'NVD sync completed', updated };
|
|
if (errors.length > 0) result.errors = errors;
|
|
res.json(result);
|
|
}
|
|
});
|
|
|
|
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
|
|
|
// Edit single CVE entry (editor or admin)
|
|
app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { id } = req.params;
|
|
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
|
|
|
// Input validation for provided fields
|
|
if (cve_id !== undefined && !isValidCveId(cve_id)) {
|
|
return res.status(400).json({ error: 'Invalid CVE ID format. Expected CVE-YYYY-NNNNN.' });
|
|
}
|
|
if (vendor !== undefined && !isValidVendor(vendor)) {
|
|
return res.status(400).json({ error: 'Vendor must be under 200 characters.' });
|
|
}
|
|
if (severity !== undefined && !VALID_SEVERITIES.includes(severity)) {
|
|
return res.status(400).json({ error: `Invalid severity. Must be one of: ${VALID_SEVERITIES.join(', ')}` });
|
|
}
|
|
if (description !== undefined && (typeof description !== 'string' || description.length > 10000)) {
|
|
return res.status(400).json({ error: 'Description must be under 10000 characters.' });
|
|
}
|
|
if (published_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(published_date)) {
|
|
return res.status(400).json({ error: 'Published date must be in YYYY-MM-DD format.' });
|
|
}
|
|
if (status !== undefined && !VALID_STATUSES.includes(status)) {
|
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
|
}
|
|
|
|
// Fetch existing row first
|
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, existing) => {
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
if (!existing) return res.status(404).json({ error: 'CVE entry not found' });
|
|
|
|
const before = { cve_id: existing.cve_id, vendor: existing.vendor, severity: existing.severity, description: existing.description, published_date: existing.published_date, status: existing.status };
|
|
|
|
const newCveId = cve_id !== undefined ? cve_id : existing.cve_id;
|
|
const newVendor = vendor !== undefined ? vendor : existing.vendor;
|
|
const cveIdChanged = newCveId !== existing.cve_id;
|
|
const vendorChanged = newVendor !== existing.vendor;
|
|
|
|
const doUpdate = () => {
|
|
// Build dynamic SET clause
|
|
const fields = [];
|
|
const values = [];
|
|
if (cve_id !== undefined) { fields.push('cve_id = ?'); values.push(cve_id); }
|
|
if (vendor !== undefined) { fields.push('vendor = ?'); values.push(vendor); }
|
|
if (severity !== undefined) { fields.push('severity = ?'); values.push(severity); }
|
|
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
|
|
if (published_date !== undefined) { fields.push('published_date = ?'); values.push(published_date); }
|
|
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
|
|
|
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
|
|
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
|
values.push(id);
|
|
|
|
db.run(`UPDATE cves SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
|
if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
const after = {
|
|
cve_id: newCveId, vendor: newVendor,
|
|
severity: severity !== undefined ? severity : existing.severity,
|
|
description: description !== undefined ? description : existing.description,
|
|
published_date: published_date !== undefined ? published_date : existing.published_date,
|
|
status: status !== undefined ? status : existing.status
|
|
};
|
|
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'cve_edit',
|
|
entityType: 'cve',
|
|
entityId: newCveId,
|
|
details: { before, after },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ message: 'CVE updated successfully', changes: this.changes });
|
|
});
|
|
};
|
|
|
|
if (cveIdChanged || vendorChanged) {
|
|
// Check UNIQUE constraint
|
|
db.get('SELECT id FROM cves WHERE cve_id = ? AND vendor = ? AND id != ?', [newCveId, newVendor, id], (checkErr, conflict) => {
|
|
if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
|
|
|
|
// Rename document directory (with path traversal prevention)
|
|
const oldDir = path.join('uploads', sanitizePathSegment(existing.cve_id), sanitizePathSegment(existing.vendor));
|
|
const newDir = path.join('uploads', sanitizePathSegment(newCveId), sanitizePathSegment(newVendor));
|
|
|
|
if (!isPathWithinUploads(oldDir) || !isPathWithinUploads(newDir)) {
|
|
return res.status(400).json({ error: 'Invalid CVE ID or vendor name for file paths.' });
|
|
}
|
|
|
|
if (fs.existsSync(oldDir)) {
|
|
const newParent = path.join('uploads', newCveId);
|
|
if (!fs.existsSync(newParent)) {
|
|
fs.mkdirSync(newParent, { recursive: true });
|
|
}
|
|
fs.renameSync(oldDir, newDir);
|
|
|
|
// Clean up old cve_id directory if empty
|
|
const oldParent = path.join('uploads', existing.cve_id);
|
|
if (fs.existsSync(oldParent)) {
|
|
const remaining = fs.readdirSync(oldParent);
|
|
if (remaining.length === 0) fs.rmdirSync(oldParent);
|
|
}
|
|
}
|
|
|
|
// Update documents table - file paths
|
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [existing.cve_id, existing.vendor], (docErr, docs) => {
|
|
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor);
|
|
const newPrefix = path.join('uploads', newCveId, newVendor);
|
|
|
|
let docUpdated = 0;
|
|
const totalDocs = docs.length;
|
|
|
|
const finishDocUpdate = () => {
|
|
if (docUpdated >= totalDocs) doUpdate();
|
|
};
|
|
|
|
if (totalDocs === 0) {
|
|
doUpdate();
|
|
} else {
|
|
docs.forEach((doc) => {
|
|
const newFilePath = doc.file_path.replace(oldPrefix, newPrefix);
|
|
db.run('UPDATE documents SET cve_id = ?, vendor = ?, file_path = ? WHERE id = ?',
|
|
[newCveId, newVendor, newFilePath, doc.id],
|
|
(docUpdateErr) => {
|
|
if (docUpdateErr) console.error('Error updating document:', docUpdateErr);
|
|
docUpdated++;
|
|
finishDocUpdate();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
} else {
|
|
doUpdate();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
|
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { cveId } = req.params;
|
|
|
|
// Get all rows for this CVE ID to know what we're deleting
|
|
db.all('SELECT * FROM cves WHERE cve_id = ?', [cveId], (err, rows) => {
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
|
|
|
// 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
|
|
// Query all three cascade-deleted resource types in parallel
|
|
db.all('SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = ?', [cveId], (archerErr, archerTickets) => {
|
|
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
db.all('SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = ?', [cveId], (jiraErr, jiraTickets) => {
|
|
// If jira_tickets table doesn't exist yet, treat as empty
|
|
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) {
|
|
jiraTickets = [];
|
|
} else if (jiraErr) {
|
|
console.error(jiraErr);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
db.all('SELECT id, name, type FROM documents WHERE cve_id = ?', [cveId], (docErr, docs) => {
|
|
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
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
|
|
// A ticket is compliance-linked if its key (exc_number or ticket_key) or cve_id
|
|
// appears in active compliance_items extra_json
|
|
const likeConditions = [];
|
|
const likeParams = [];
|
|
for (const t of allTickets) {
|
|
likeConditions.push('ci.extra_json LIKE ?');
|
|
likeParams.push(`%${t.key}%`);
|
|
}
|
|
// Also check if the CVE ID itself appears in compliance extra_json
|
|
likeConditions.push('ci.extra_json LIKE ?');
|
|
likeParams.push(`%${cveId}%`);
|
|
|
|
db.all(
|
|
`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,
|
|
(compErr, compLinks) => {
|
|
// If compliance_items table doesn't exist yet, treat as no linkage
|
|
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
|
compLinks = [];
|
|
} else if (compErr) {
|
|
console.error(compErr);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
// Determine which tickets are compliance-linked by checking extra_json matches
|
|
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 CVE ID itself is in compliance data, all tickets are considered linked
|
|
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
|
|
}
|
|
});
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
return; // Exit early — Standard_User flow handled above
|
|
}
|
|
|
|
// Admin flow: proceed directly with deletion (no cascade check)
|
|
// Delete all documents from DB
|
|
db.run('DELETE FROM documents WHERE cve_id = ?', [cveId], (docErr) => {
|
|
if (docErr) console.error('Error deleting documents:', docErr);
|
|
|
|
// Delete all CVE rows
|
|
db.run('DELETE FROM cves WHERE cve_id = ?', [cveId], function(cveErr) {
|
|
if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
// Remove upload directory (with path traversal prevention)
|
|
const safeCveId = sanitizePathSegment(cveId);
|
|
const cveDir = path.join('uploads', safeCveId);
|
|
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
|
|
fs.rmSync(cveDir, { recursive: true, force: true });
|
|
}
|
|
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'cve_delete',
|
|
entityType: 'cve',
|
|
entityId: cveId,
|
|
details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: this.changes });
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Delete single CVE vendor entry (editor or admin)
|
|
app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
|
|
|
// 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') {
|
|
return db.all('SELECT id, exc_number FROM archer_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (archerErr, archerTickets) => {
|
|
if (archerErr) { console.error(archerErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
db.all('SELECT id, ticket_key FROM jira_tickets WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (jiraErr, jiraTickets) => {
|
|
if (jiraErr && jiraErr.message && jiraErr.message.includes('no such table')) { jiraTickets = []; }
|
|
else if (jiraErr) { console.error(jiraErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
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) {
|
|
return doSingleCveDelete(req, res, id, cve);
|
|
}
|
|
|
|
const likeConditions = allTickets.map(() => 'ci.extra_json LIKE ?');
|
|
const likeParams = allTickets.map(t => `%${t.key}%`);
|
|
|
|
db.all(
|
|
`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,
|
|
(compErr, compLinks) => {
|
|
if (compErr && compErr.message && compErr.message.includes('no such table')) { compLinks = []; }
|
|
else if (compErr) { console.error(compErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
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' }
|
|
});
|
|
}
|
|
|
|
return doSingleCveDelete(req, res, id, cve);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
doSingleCveDelete(req, res, id, cve);
|
|
});
|
|
|
|
function doSingleCveDelete(req, res, id, cve) {
|
|
// Delete associated documents from DB
|
|
db.all('SELECT id, file_path FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (docErr, docs) => {
|
|
if (docErr) console.error('Error fetching documents:', docErr);
|
|
|
|
// Delete document files from disk (with path traversal prevention)
|
|
if (docs && docs.length > 0) {
|
|
docs.forEach(doc => {
|
|
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
|
|
fs.unlinkSync(doc.file_path);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Delete documents from DB
|
|
db.run('DELETE FROM documents WHERE cve_id = ? AND vendor = ?', [cve.cve_id, cve.vendor], (delDocErr) => {
|
|
if (delDocErr) console.error('Error deleting documents from DB:', delDocErr);
|
|
|
|
// Delete CVE row
|
|
db.run('DELETE FROM cves WHERE id = ?', [id], function(delErr) {
|
|
if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
|
|
|
// Clean up directories (with path traversal prevention)
|
|
const safeVendorDir = path.join('uploads', sanitizePathSegment(cve.cve_id), sanitizePathSegment(cve.vendor));
|
|
if (isPathWithinUploads(safeVendorDir) && fs.existsSync(safeVendorDir)) {
|
|
fs.rmSync(safeVendorDir, { recursive: true, force: true });
|
|
}
|
|
const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id));
|
|
if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) {
|
|
const remaining = fs.readdirSync(safeCveDir);
|
|
if (remaining.length === 0) fs.rmdirSync(safeCveDir);
|
|
}
|
|
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'cve_delete',
|
|
entityType: 'cve',
|
|
entityId: cve.cve_id,
|
|
details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` });
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// ========== DOCUMENT ENDPOINTS ==========
|
|
|
|
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
|
app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
|
const { cveId } = req.params;
|
|
const { vendor } = req.query; // NEW: Optional vendor filter
|
|
|
|
let query = `SELECT * FROM documents WHERE cve_id = ?`;
|
|
let params = [cveId];
|
|
|
|
if (vendor) {
|
|
query += ` AND vendor = ?`;
|
|
params.push(vendor);
|
|
}
|
|
|
|
query += ` ORDER BY uploaded_at DESC`;
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
|
|
app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
|
upload.single('file')(req, res, (err) => {
|
|
if (err) {
|
|
console.error('Upload error:', err.message);
|
|
// Show file validation errors to the user; hide other internal errors
|
|
if (err.message && (err.message.startsWith('File type') || err.message.startsWith('MIME type'))) {
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
return res.status(400).json({ error: 'File exceeds the 10MB size limit.' });
|
|
}
|
|
return res.status(500).json({ error: 'File upload failed.' });
|
|
}
|
|
|
|
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) {
|
|
// Clean up temp file
|
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
|
return res.status(400).json({ error: 'Vendor is required' });
|
|
}
|
|
|
|
// Validate document type
|
|
if (type && !VALID_DOC_TYPES.includes(type)) {
|
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
|
return res.status(400).json({ error: `Invalid document type. Must be one of: ${VALID_DOC_TYPES.join(', ')}` });
|
|
}
|
|
|
|
// Sanitize path segments to prevent directory traversal
|
|
const safeCveId = sanitizePathSegment(cveId);
|
|
const safeVendor = sanitizePathSegment(vendor);
|
|
const safeFilename = sanitizePathSegment(file.filename);
|
|
|
|
if (!safeCveId || !safeVendor || !safeFilename) {
|
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
|
return res.status(400).json({ error: 'Invalid CVE ID, vendor, or filename.' });
|
|
}
|
|
|
|
// Move file from temp to proper location
|
|
const finalDir = path.join('uploads', 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 query = `
|
|
INSERT INTO documents (cve_id, vendor, name, type, file_path, file_size, mime_type, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`;
|
|
|
|
const fileSizeKB = (file.size / 1024).toFixed(2) + ' KB';
|
|
|
|
db.run(query, [
|
|
cveId,
|
|
vendor,
|
|
file.originalname,
|
|
type,
|
|
finalPath,
|
|
fileSizeKB,
|
|
file.mimetype,
|
|
notes
|
|
], function(err) {
|
|
if (err) {
|
|
console.error('Document insert error:', err);
|
|
// If database insert fails, delete the file
|
|
if (fs.existsSync(finalPath)) {
|
|
fs.unlinkSync(finalPath);
|
|
}
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'document_upload',
|
|
entityType: 'document',
|
|
entityId: cveId,
|
|
details: { vendor, type, filename: file.originalname },
|
|
ipAddress: req.ip
|
|
});
|
|
res.json({
|
|
id: this.lastID,
|
|
message: 'Document uploaded successfully',
|
|
file: {
|
|
name: file.originalname,
|
|
size: fileSizeKB
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
// Delete document (admin only)
|
|
app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
// First get the file path to delete the actual file
|
|
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
// Only delete file if path is within uploads directory
|
|
if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) {
|
|
fs.unlinkSync(row.file_path);
|
|
}
|
|
|
|
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'document_delete',
|
|
entityType: 'document',
|
|
entityId: id,
|
|
details: { file_path: row ? row.file_path : null },
|
|
ipAddress: req.ip
|
|
});
|
|
res.json({ message: 'Document deleted successfully' });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ========== UTILITY ENDPOINTS ==========
|
|
|
|
// Get all vendors (authenticated users)
|
|
app.get('/api/vendors', requireAuth(db), (req, res) => {
|
|
const query = `SELECT DISTINCT vendor FROM cves ORDER BY vendor`;
|
|
|
|
db.all(query, [], (err, rows) => {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(rows.map(r => r.vendor));
|
|
});
|
|
});
|
|
|
|
// Get statistics (authenticated users)
|
|
app.get('/api/stats', requireAuth(db), (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
|
|
`;
|
|
|
|
db.get(query, [], (err, row) => {
|
|
if (err) {
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(row);
|
|
});
|
|
});
|
|
|
|
// ========== JIRA TICKET ENDPOINTS ==========
|
|
|
|
// Get all JIRA tickets (with optional filters)
|
|
app.get('/api/jira-tickets', requireAuth(db), (req, res) => {
|
|
const { cve_id, vendor, status } = req.query;
|
|
|
|
let query = 'SELECT * FROM jira_tickets WHERE 1=1';
|
|
const params = [];
|
|
|
|
if (cve_id) {
|
|
query += ' AND cve_id = ?';
|
|
params.push(cve_id);
|
|
}
|
|
if (vendor) {
|
|
query += ' AND vendor = ?';
|
|
params.push(vendor);
|
|
}
|
|
if (status) {
|
|
query += ' AND status = ?';
|
|
params.push(status);
|
|
}
|
|
|
|
query += ' ORDER BY created_at DESC';
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching JIRA tickets:', err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
// Create JIRA ticket
|
|
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { cve_id, vendor, ticket_key, url, summary, status } = req.body;
|
|
|
|
// Validation
|
|
if (!cve_id || !isValidCveId(cve_id)) {
|
|
return res.status(400).json({ error: 'Valid CVE ID is required.' });
|
|
}
|
|
if (!vendor || !isValidVendor(vendor)) {
|
|
return res.status(400).json({ error: 'Valid vendor is required.' });
|
|
}
|
|
if (!ticket_key || typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50) {
|
|
return res.status(400).json({ error: 'Ticket key is required (max 50 chars).' });
|
|
}
|
|
if (url && (typeof url !== 'string' || url.length > 500)) {
|
|
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
|
}
|
|
if (summary && (typeof summary !== 'string' || summary.length > 500)) {
|
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
|
}
|
|
if (status && !VALID_TICKET_STATUSES.includes(status)) {
|
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
|
}
|
|
|
|
const ticketStatus = status || 'Open';
|
|
|
|
const query = `
|
|
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`;
|
|
|
|
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
|
|
if (err) {
|
|
console.error('Error creating JIRA ticket:', err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'jira_ticket_create',
|
|
entityType: 'jira_ticket',
|
|
entityId: this.lastID.toString(),
|
|
details: { cve_id, vendor, ticket_key, status: ticketStatus },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.status(201).json({
|
|
id: this.lastID,
|
|
message: 'JIRA ticket created successfully'
|
|
});
|
|
});
|
|
});
|
|
|
|
// Update JIRA ticket
|
|
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { id } = req.params;
|
|
const { ticket_key, url, summary, status } = req.body;
|
|
|
|
// Validation
|
|
if (ticket_key !== undefined && (typeof ticket_key !== 'string' || ticket_key.trim().length === 0 || ticket_key.length > 50)) {
|
|
return res.status(400).json({ error: 'Ticket key must be under 50 chars.' });
|
|
}
|
|
if (url !== undefined && url !== null && (typeof url !== 'string' || url.length > 500)) {
|
|
return res.status(400).json({ error: 'URL must be under 500 characters.' });
|
|
}
|
|
if (summary !== undefined && summary !== null && (typeof summary !== 'string' || summary.length > 500)) {
|
|
return res.status(400).json({ error: 'Summary must be under 500 characters.' });
|
|
}
|
|
if (status !== undefined && !VALID_TICKET_STATUSES.includes(status)) {
|
|
return res.status(400).json({ error: `Status must be one of: ${VALID_TICKET_STATUSES.join(', ')}` });
|
|
}
|
|
|
|
// Build dynamic update
|
|
const fields = [];
|
|
const values = [];
|
|
|
|
if (ticket_key !== undefined) { fields.push('ticket_key = ?'); values.push(ticket_key.trim()); }
|
|
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
|
|
if (summary !== undefined) { fields.push('summary = ?'); values.push(summary); }
|
|
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
|
|
|
|
if (fields.length === 0) {
|
|
return res.status(400).json({ error: 'No fields to update.' });
|
|
}
|
|
|
|
fields.push('updated_at = CURRENT_TIMESTAMP');
|
|
values.push(id);
|
|
|
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, existing) => {
|
|
if (err) {
|
|
console.error(err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
}
|
|
|
|
db.run(`UPDATE jira_tickets SET ${fields.join(', ')} WHERE id = ?`, values, function(updateErr) {
|
|
if (updateErr) {
|
|
console.error('Error updating JIRA ticket:', updateErr);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'jira_ticket_update',
|
|
entityType: 'jira_ticket',
|
|
entityId: id,
|
|
details: { before: existing, changes: req.body },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ message: 'JIRA ticket updated successfully', changes: this.changes });
|
|
});
|
|
});
|
|
});
|
|
|
|
// Delete JIRA ticket
|
|
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
db.get('SELECT * FROM jira_tickets WHERE id = ?', [id], (err, ticket) => {
|
|
if (err) {
|
|
console.error(err);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
if (!ticket) {
|
|
return res.status(404).json({ error: 'JIRA ticket not found.' });
|
|
}
|
|
|
|
// Admin bypasses all delete restrictions
|
|
if (req.user.group === 'Admin') {
|
|
return performJiraDelete();
|
|
}
|
|
|
|
// Standard_User: ownership check
|
|
if (ticket.created_by && ticket.created_by !== req.user.id) {
|
|
return res.status(403).json({ error: 'You can only delete resources you created' });
|
|
}
|
|
|
|
// Standard_User: compliance linkage check
|
|
const ticketKey = ticket.ticket_key;
|
|
db.all(
|
|
`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 ci.extra_json LIKE ?`,
|
|
[`%${ticketKey}%`],
|
|
(compErr, compLinks) => {
|
|
// If compliance_items table doesn't exist yet, treat as no linkage
|
|
if (compErr && compErr.message && compErr.message.includes('no such table')) {
|
|
compLinks = [];
|
|
} else if (compErr) {
|
|
console.error(compErr);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
const isLinked = (compLinks || []).some(cl => {
|
|
const json = cl.extra_json || '';
|
|
return json.includes(ticketKey);
|
|
});
|
|
|
|
if (isLinked) {
|
|
return res.status(403).json({ error: 'Cannot delete ticket linked to compliance report. Contact an admin.' });
|
|
}
|
|
|
|
return performJiraDelete();
|
|
}
|
|
);
|
|
|
|
function performJiraDelete() {
|
|
db.run('DELETE FROM jira_tickets WHERE id = ?', [id], function(deleteErr) {
|
|
if (deleteErr) {
|
|
console.error('Error deleting JIRA ticket:', deleteErr);
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
|
}
|
|
|
|
logAudit(db, {
|
|
userId: req.user.id,
|
|
username: req.user.username,
|
|
action: 'jira_ticket_delete',
|
|
entityType: 'jira_ticket',
|
|
entityId: id,
|
|
details: { ticket_key: ticket.ticket_key, cve_id: ticket.cve_id, vendor: ticket.vendor },
|
|
ipAddress: req.ip
|
|
});
|
|
|
|
res.json({ message: 'JIRA ticket deleted successfully' });
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
|
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
|
});
|