2026-01-27 04:06:03 +00:00
|
|
|
// CVE Management Backend API
|
2026-01-28 14:36:33 -07:00
|
|
|
// Install: npm install express sqlite3 multer cors dotenv bcryptjs cookie-parser
|
2026-01-28 09:23:30 -07:00
|
|
|
|
|
|
|
|
require('dotenv').config();
|
2026-01-27 04:06:03 +00:00
|
|
|
|
|
|
|
|
const express = require('express');
|
|
|
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
|
|
|
const multer = require('multer');
|
|
|
|
|
const cors = require('cors');
|
2026-01-28 14:36:33 -07:00
|
|
|
const cookieParser = require('cookie-parser');
|
2026-01-27 04:06:03 +00:00
|
|
|
const path = require('path');
|
|
|
|
|
const fs = require('fs');
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Auth imports
|
2026-04-06 16:18:07 -06:00
|
|
|
const { requireAuth, requireGroup } = require('./middleware/auth');
|
2026-01-28 14:36:33 -07:00
|
|
|
const createAuthRouter = require('./routes/auth');
|
|
|
|
|
const createUsersRouter = require('./routes/users');
|
2026-01-29 15:10:29 -07:00
|
|
|
const createAuditLogRouter = require('./routes/auditLog');
|
|
|
|
|
const logAudit = require('./helpers/auditLog');
|
2026-02-02 10:50:38 -07:00
|
|
|
const createNvdLookupRouter = require('./routes/nvdLookup');
|
2026-02-13 09:43:09 -07:00
|
|
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
2026-02-18 15:02:25 -07:00
|
|
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
2026-03-10 15:29:33 -06:00
|
|
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const createIvantiFindingsRouter = require('./routes/ivantiFindings');
|
feat(reporting): add Ivanti queue panel for batch FP/Archer staging
Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.
Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
DELETE/completed — all scoped to req.user.id
Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:10:53 -06:00
|
|
|
const createIvantiTodoQueueRouter = require('./routes/ivantiTodoQueue');
|
2026-04-07 16:20:24 -06:00
|
|
|
const createIvantiArchiveRouter = require('./routes/ivantiArchive');
|
|
|
|
|
const createIvantiFpWorkflowRouter = require('./routes/ivantiFpWorkflow');
|
|
|
|
|
const createComplianceRouter = require('./routes/compliance');
|
2026-01-28 14:36:33 -07:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
const app = express();
|
2026-01-28 09:23:30 -07:00
|
|
|
const PORT = process.env.PORT || 3001;
|
|
|
|
|
const API_HOST = process.env.API_HOST || 'localhost';
|
2026-04-07 10:23:10 -06:00
|
|
|
const SESSION_SECRET = process.env.SESSION_SECRET;
|
|
|
|
|
if (!SESSION_SECRET) {
|
|
|
|
|
console.error('FATAL: SESSION_SECRET environment variable must be set');
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
2026-01-28 09:23:30 -07:00
|
|
|
const CORS_ORIGINS = process.env.CORS_ORIGINS
|
|
|
|
|
? process.env.CORS_ORIGINS.split(',')
|
|
|
|
|
: ['http://localhost:3000'];
|
2026-01-27 04:06:03 +00:00
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// ========== 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',
|
2026-02-17 08:56:10 -07:00
|
|
|
'.txt', '.md', '.csv', '.log', '.msg', '.eml',
|
2026-02-02 14:39:50 -07:00
|
|
|
'.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',
|
2026-02-02 16:11:43 -07:00
|
|
|
'application/vnd.ms-outlook', 'message/rfc822',
|
2026-02-02 14:39:50 -07:00
|
|
|
'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'];
|
2026-02-09 11:56:34 -07:00
|
|
|
const VALID_TICKET_STATUSES = ['Open', 'In Progress', 'Closed'];
|
2026-02-02 14:39:50 -07:00
|
|
|
|
|
|
|
|
// Validate vendor name - printable chars, reasonable length
|
|
|
|
|
function isValidVendor(vendor) {
|
|
|
|
|
return typeof vendor === 'string' && vendor.trim().length > 0 && vendor.length <= 200;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 14:49:03 +00:00
|
|
|
// Log all incoming requests
|
|
|
|
|
app.use((req, res, next) => {
|
|
|
|
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// Security headers
|
|
|
|
|
app.use((req, res, next) => {
|
|
|
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
2026-02-13 10:50:37 -07:00
|
|
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); // Allow iframes from same origin
|
2026-02-02 14:39:50 -07:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
// Middleware
|
|
|
|
|
app.use(cors({
|
2026-01-28 09:23:30 -07:00
|
|
|
origin: CORS_ORIGINS,
|
2026-01-27 04:06:03 +00:00
|
|
|
credentials: true
|
|
|
|
|
}));
|
2026-02-17 08:52:26 -07:00
|
|
|
// Only parse JSON for requests with application/json content type
|
|
|
|
|
app.use(express.json({
|
|
|
|
|
limit: '1mb',
|
|
|
|
|
type: 'application/json'
|
|
|
|
|
}));
|
2026-01-28 14:36:33 -07:00
|
|
|
app.use(cookieParser());
|
2026-02-02 14:39:50 -07:00
|
|
|
app.use('/uploads', express.static('uploads', {
|
|
|
|
|
dotfiles: 'deny',
|
|
|
|
|
index: false
|
|
|
|
|
}));
|
2026-01-27 04:06:03 +00:00
|
|
|
|
|
|
|
|
// Database connection
|
|
|
|
|
const db = new sqlite3.Database('./cve_database.db', (err) => {
|
feat(reporting): add Ivanti queue panel for batch FP/Archer staging
Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.
Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
DELETE/completed — all scoped to req.user.id
Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:10:53 -06:00
|
|
|
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,
|
2026-03-26 15:01:32 -06:00
|
|
|
ip_address TEXT,
|
feat(reporting): add Ivanti queue panel for batch FP/Archer staging
Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.
Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
DELETE/completed — all scoped to req.user.id
Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:10:53 -06:00
|
|
|
vendor TEXT NOT NULL,
|
2026-03-26 14:46:59 -06:00
|
|
|
workflow_type TEXT NOT NULL CHECK(workflow_type IN ('FP', 'Archer', 'CARD')),
|
feat(reporting): add Ivanti queue panel for batch FP/Archer staging
Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.
Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
DELETE/completed — all scoped to req.user.id
Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:10:53 -06:00
|
|
|
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); }
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Auth routes (public)
|
2026-01-29 15:10:29 -07:00
|
|
|
app.use('/api/auth', createAuthRouter(db, logAudit));
|
2026-01-28 14:36:33 -07:00
|
|
|
|
|
|
|
|
// User management routes (admin only)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.use('/api/users', createUsersRouter(db, requireAuth, requireGroup, logAudit));
|
2026-01-29 15:10:29 -07:00
|
|
|
|
|
|
|
|
// Audit log routes (admin only)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.use('/api/audit-logs', createAuditLogRouter(db, requireAuth, requireGroup));
|
2026-01-28 14:36:33 -07:00
|
|
|
|
2026-02-02 10:50:38 -07:00
|
|
|
// NVD lookup routes (authenticated users)
|
|
|
|
|
app.use('/api/nvd', createNvdLookupRouter(db, requireAuth));
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
// 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();
|
2026-02-02 14:39:50 -07:00
|
|
|
// Sanitize original filename - strip path components and dangerous chars
|
|
|
|
|
const safeName = sanitizePathSegment(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
|
|
|
cb(null, `${timestamp}-${safeName}`);
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// 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({
|
2026-01-27 04:06:03 +00:00
|
|
|
storage: storage,
|
2026-02-02 14:39:50 -07:00
|
|
|
fileFilter: fileFilter,
|
2026-01-27 04:06:03 +00:00
|
|
|
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-13 09:43:09 -07:00
|
|
|
// Knowledge base routes (editor/admin for upload/delete, all authenticated for view)
|
|
|
|
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
|
|
|
|
|
2026-02-18 15:02:25 -07:00
|
|
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
|
|
|
|
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
|
|
|
|
|
2026-03-10 15:29:33 -06:00
|
|
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
|
|
|
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// Ivanti / RiskSense host findings routes (all authenticated users)
|
|
|
|
|
app.use('/api/ivanti/findings', createIvantiFindingsRouter(db, requireAuth));
|
|
|
|
|
|
feat(reporting): add Ivanti queue panel for batch FP/Archer staging
Adds a persistent per-user staging queue so analysts can tag findings
during review and batch-process Ivanti workflows in one focused session.
Backend:
- New ivanti_todo_queue table (user-scoped, vendor, workflow_type, status)
- Table auto-created on server startup via idempotent CREATE IF NOT EXISTS
- New route /api/ivanti/todo-queue: GET, POST, PUT/:id, DELETE/:id,
DELETE/completed — all scoped to req.user.id
Frontend (ReportingPage):
- Fixed checkbox column on findings table; clicking opens an add-to-queue
popover (portal) with vendor input and FP/Archer toggle
- Already-queued rows show checked/disabled checkbox
- Queue slide-out panel (420px fixed, CSS transition) with items grouped
by vendor, per-item complete toggle + delete, Clear Completed footer
- Queue button in header with live pending-count badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 14:10:53 -06:00
|
|
|
// Ivanti queue routes — per-user staging queue for FP / Archer workflows
|
|
|
|
|
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter(db, requireAuth));
|
|
|
|
|
|
2026-04-03 15:20:04 -06:00
|
|
|
// Ivanti archive routes — finding archive tracking for severity score drift
|
|
|
|
|
app.use('/api/ivanti/archive', createIvantiArchiveRouter(db, requireAuth));
|
|
|
|
|
|
2026-04-07 16:20:24 -06:00
|
|
|
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
|
|
|
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter(db, requireAuth));
|
|
|
|
|
|
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
|
|
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
2026-04-06 16:18:07 -06:00
|
|
|
app.use('/api/compliance', createComplianceRouter(db, upload, requireAuth, requireGroup));
|
feat(compliance): add AEO compliance tracking backend
- Migration: compliance_uploads, compliance_items, compliance_notes tables
with indexes on (hostname, metric_id) identity key and team/status
- Python parser (parse_compliance_xlsx.py): reads NTS_AEO xlsx, extracts
non-compliant assets from all detail sheets, parses Summary sheet for
metric health data and overall scores, outputs JSON to stdout
- Route (/api/compliance): preview/commit upload flow with diff summary,
items endpoint grouped by hostname with seen_count tracking, metric
summary endpoint for health cards, notes endpoints keyed on
(hostname, metric_id) persisting across uploads
- server.js: register compliance router at /api/compliance
- .gitignore: exclude planning docs and xlsx source files
2026-03-31 15:06:59 -06:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
// ========== CVE ENDPOINTS ==========
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Get all CVEs with optional filters (authenticated users)
|
|
|
|
|
app.get('/api/cves', requireAuth(db), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { search, vendor, severity, status } = req.query;
|
|
|
|
|
|
|
|
|
|
let query = `
|
2026-02-02 16:32:44 -07:00
|
|
|
SELECT c.*, COUNT(d.id) as document_count
|
2026-01-27 04:06:03 +00:00
|
|
|
FROM cves c
|
2026-02-02 16:32:44 -07:00
|
|
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
2026-01-27 04:06:03 +00:00
|
|
|
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);
|
2026-02-02 14:39:50 -07:00
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
res.json(rows);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-02 10:50:38 -07:00
|
|
|
// 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) => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 10:50:38 -07:00
|
|
|
res.json(rows.map(r => r.cve_id));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
|
|
|
|
app.get('/api/cves/check/:cveId', requireAuth(db), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { cveId } = req.params;
|
|
|
|
|
|
|
|
|
|
const query = `
|
2026-02-02 16:32:44 -07:00
|
|
|
SELECT c.*,
|
2026-01-27 04:06:03 +00:00
|
|
|
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
|
2026-01-27 23:00:12 +00:00
|
|
|
LEFT JOIN documents d ON c.cve_id = d.cve_id AND c.vendor = d.vendor
|
2026-01-27 04:06:03 +00:00
|
|
|
WHERE c.cve_id = ?
|
|
|
|
|
GROUP BY c.id
|
|
|
|
|
`;
|
2026-02-02 16:32:44 -07:00
|
|
|
|
2026-01-27 23:00:12 +00:00
|
|
|
db.all(query, [cveId], (err, rows) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-01-27 23:00:12 +00:00
|
|
|
if (!rows || rows.length === 0) {
|
2026-02-02 16:32:44 -07:00
|
|
|
return res.json({
|
|
|
|
|
exists: false,
|
|
|
|
|
message: 'CVE not found - not yet addressed'
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 16:32:44 -07:00
|
|
|
|
2026-01-27 23:00:12 +00:00
|
|
|
// Return all vendor entries for this CVE
|
2026-01-27 04:06:03 +00:00
|
|
|
res.json({
|
|
|
|
|
exists: true,
|
2026-01-27 23:00:12 +00:00
|
|
|
vendors: rows.map(row => ({
|
|
|
|
|
vendor: row.vendor,
|
|
|
|
|
severity: row.severity,
|
|
|
|
|
status: row.status,
|
|
|
|
|
total_documents: row.total_documents,
|
2026-02-02 16:32:44 -07:00
|
|
|
doc_types: {
|
2026-01-27 23:00:12 +00:00
|
|
|
email: row.has_email > 0,
|
|
|
|
|
screenshot: row.has_screenshot > 0
|
|
|
|
|
}
|
|
|
|
|
})),
|
2026-02-02 16:32:44 -07:00
|
|
|
addressed: true
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// NEW ENDPOINT: Get all vendors for a specific CVE (authenticated users)
|
|
|
|
|
app.get('/api/cves/:cveId/vendors', requireAuth(db), (req, res) => {
|
2026-01-27 23:00:12 +00:00
|
|
|
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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 23:00:12 +00:00
|
|
|
}
|
|
|
|
|
res.json(rows);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 11:39:26 -06:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.post('/api/cves', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-01-28 14:49:03 +00:00
|
|
|
const { cve_id, vendor, severity, description, published_date } = req.body;
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// 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.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
const query = `
|
2026-04-06 16:18:07 -06:00
|
|
|
INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
2026-01-27 04:06:03 +00:00
|
|
|
`;
|
2026-01-28 14:49:03 +00:00
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
db.run(query, [cve_id, vendor, severity, description, published_date, req.user.id], function(err) {
|
2026-01-27 04:06:03 +00:00
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error('DATABASE ERROR:', err);
|
2026-01-27 23:00:12 +00:00
|
|
|
if (err.message.includes('UNIQUE constraint failed')) {
|
2026-02-02 14:39:50 -07:00
|
|
|
return res.status(409).json({
|
|
|
|
|
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
2026-01-27 23:00:12 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 14:39:50 -07:00
|
|
|
return res.status(500).json({ error: 'Failed to create CVE entry.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-01-29 15:10:29 -07:00
|
|
|
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,
|
2026-01-27 04:06:03 +00:00
|
|
|
cve_id,
|
2026-01-29 15:10:29 -07:00
|
|
|
message: `CVE created successfully for vendor: ${vendor}`
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-27 23:00:12 +00:00
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Update CVE status (editor or admin)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.patch('/api/cves/:cveId/status', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { cveId } = req.params;
|
|
|
|
|
const { status } = req.body;
|
2026-02-02 14:39:50 -07:00
|
|
|
|
|
|
|
|
if (!status || !VALID_STATUSES.includes(status)) {
|
|
|
|
|
return res.status(400).json({ error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}` });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
const query = `UPDATE cves SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE cve_id = ?`;
|
2026-01-29 15:10:29 -07:00
|
|
|
|
|
|
|
|
db.run(query, [status, cveId], function(err) {
|
2026-01-27 04:06:03 +00:00
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-01-29 15:10:29 -07:00
|
|
|
logAudit(db, {
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_update_status',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: cveId,
|
|
|
|
|
details: { status },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
2026-01-27 04:06:03 +00:00
|
|
|
res.json({ message: 'Status updated successfully', changes: this.changes });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-02 10:50:38 -07:00
|
|
|
// Bulk sync CVE data from NVD (editor or admin)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.post('/api/cves/nvd-sync', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-02 10:50:38 -07:00
|
|
|
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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error('NVD sync update error:', err);
|
|
|
|
|
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
|
2026-02-02 10:50:38 -07:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-02 11:33:44 -07:00
|
|
|
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
|
|
|
|
|
|
|
|
|
// Edit single CVE entry (editor or admin)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.put('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-02 11:33:44 -07:00
|
|
|
const { id } = req.params;
|
|
|
|
|
const { cve_id, vendor, severity, description, published_date, status } = req.body;
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// 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(', ')}` });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 11:33:44 -07:00
|
|
|
// Fetch existing row first
|
|
|
|
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, existing) => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (updateErr) { console.error(updateErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
|
|
|
|
|
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) => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (checkErr) { console.error(checkErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
if (conflict) return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// 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.' });
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
|
|
|
|
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) => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (docErr) { console.error(docErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
|
|
|
|
|
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
|
2026-04-06 16:18:07 -06:00
|
|
|
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-02 11:33:44 -07:00
|
|
|
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) => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
if (!rows || rows.length === 0) return res.status(404).json({ error: 'No CVE entries found for this CVE ID' });
|
|
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// 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)
|
2026-02-02 11:33:44 -07:00
|
|
|
// 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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (cveErr) { console.error(cveErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// Remove upload directory (with path traversal prevention)
|
|
|
|
|
const safeCveId = sanitizePathSegment(cveId);
|
|
|
|
|
const cveDir = path.join('uploads', safeCveId);
|
|
|
|
|
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
|
2026-02-02 11:33:44 -07:00
|
|
|
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)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.delete('/api/cves/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-02 11:33:44 -07:00
|
|
|
const { id } = req.params;
|
|
|
|
|
|
|
|
|
|
db.get('SELECT * FROM cves WHERE id = ?', [id], (err, cve) => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (err) { console.error(err); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
if (!cve) return res.status(404).json({ error: 'CVE entry not found' });
|
|
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// 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' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 09:52:26 -06:00
|
|
|
// 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) {
|
2026-02-02 11:33:44 -07:00
|
|
|
// 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);
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// Delete document files from disk (with path traversal prevention)
|
2026-02-02 11:33:44 -07:00
|
|
|
if (docs && docs.length > 0) {
|
|
|
|
|
docs.forEach(doc => {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
|
2026-02-02 11:33:44 -07:00
|
|
|
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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
if (delErr) { console.error(delErr); return res.status(500).json({ error: 'Internal server error.' }); }
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// 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 });
|
2026-02-02 11:33:44 -07:00
|
|
|
}
|
2026-02-02 14:39:50 -07:00
|
|
|
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);
|
2026-02-02 11:33:44 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}` });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-07 09:52:26 -06:00
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
// ========== DOCUMENT ENDPOINTS ==========
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Get documents for a CVE - FILTER BY VENDOR (authenticated users)
|
|
|
|
|
app.get('/api/cves/:cveId/documents', requireAuth(db), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { cveId } = req.params;
|
2026-01-27 23:00:12 +00:00
|
|
|
const { vendor } = req.query; // NEW: Optional vendor filter
|
2026-01-27 04:06:03 +00:00
|
|
|
|
2026-01-27 23:00:12 +00:00
|
|
|
let query = `SELECT * FROM documents WHERE cve_id = ?`;
|
|
|
|
|
let params = [cveId];
|
2026-01-27 04:06:03 +00:00
|
|
|
|
2026-01-27 23:00:12 +00:00
|
|
|
if (vendor) {
|
|
|
|
|
query += ` AND vendor = ?`;
|
|
|
|
|
params.push(vendor);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query += ` ORDER BY uploaded_at DESC`;
|
|
|
|
|
|
|
|
|
|
db.all(query, params, (err, rows) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
res.json(rows);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.post('/api/cves/:cveId/documents', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
2026-01-28 14:49:03 +00:00
|
|
|
upload.single('file')(req, res, (err) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
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.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-01-28 14:49:03 +00:00
|
|
|
|
|
|
|
|
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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
// Clean up temp file
|
|
|
|
|
if (file.path && fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
2026-01-28 14:49:03 +00:00
|
|
|
return res.status(400).json({ error: 'Vendor is required' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 14:39:50 -07:00
|
|
|
// 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.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 14:49:03 +00:00
|
|
|
// Move file from temp to proper location
|
2026-02-02 14:39:50 -07:00
|
|
|
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.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 14:49:03 +00:00
|
|
|
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) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error('Document insert error:', err);
|
2026-01-28 14:49:03 +00:00
|
|
|
// If database insert fails, delete the file
|
|
|
|
|
if (fs.existsSync(finalPath)) {
|
|
|
|
|
fs.unlinkSync(finalPath);
|
|
|
|
|
}
|
2026-02-02 14:39:50 -07:00
|
|
|
return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-01-29 15:10:29 -07:00
|
|
|
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
|
|
|
|
|
});
|
2026-01-28 14:49:03 +00:00
|
|
|
res.json({
|
|
|
|
|
id: this.lastID,
|
|
|
|
|
message: 'Document uploaded successfully',
|
|
|
|
|
file: {
|
|
|
|
|
name: file.originalname,
|
|
|
|
|
size: fileSizeKB
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-01-28 14:36:33 -07:00
|
|
|
// Delete document (admin only)
|
2026-04-06 16:18:07 -06:00
|
|
|
app.delete('/api/documents/:id', requireAuth(db), requireGroup('Admin'), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { id } = req.params;
|
2026-02-02 14:39:50 -07:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
// First get the file path to delete the actual file
|
|
|
|
|
db.get('SELECT file_path FROM documents WHERE id = ?', [id], (err, row) => {
|
|
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-02-02 14:39:50 -07:00
|
|
|
|
|
|
|
|
// Only delete file if path is within uploads directory
|
|
|
|
|
if (row && row.file_path && isPathWithinUploads(row.file_path) && fs.existsSync(row.file_path)) {
|
2026-01-27 04:06:03 +00:00
|
|
|
fs.unlinkSync(row.file_path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.run('DELETE FROM documents WHERE id = ?', [id], function(err) {
|
|
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
2026-01-29 15:10:29 -07:00
|
|
|
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
|
|
|
|
|
});
|
2026-01-27 04:06:03 +00:00
|
|
|
res.json({ message: 'Document deleted successfully' });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ========== UTILITY ENDPOINTS ==========
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Get all vendors (authenticated users)
|
|
|
|
|
app.get('/api/vendors', requireAuth(db), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const query = `SELECT DISTINCT vendor FROM cves ORDER BY vendor`;
|
|
|
|
|
|
|
|
|
|
db.all(query, [], (err, rows) => {
|
|
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
res.json(rows.map(r => r.vendor));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Get statistics (authenticated users)
|
|
|
|
|
app.get('/api/stats', requireAuth(db), (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const query = `
|
2026-02-09 11:56:34 -07:00
|
|
|
SELECT
|
2026-01-27 04:06:03 +00:00
|
|
|
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
|
|
|
|
|
`;
|
2026-02-09 11:56:34 -07:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
db.get(query, [], (err, row) => {
|
|
|
|
|
if (err) {
|
2026-02-02 14:39:50 -07:00
|
|
|
console.error(err); return res.status(500).json({ error: 'Internal server error.' });
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
res.json(row);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-09 11:56:34 -07:00
|
|
|
// ========== 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
|
2026-04-06 16:18:07 -06:00
|
|
|
app.post('/api/jira-tickets', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-09 11:56:34 -07:00
|
|
|
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 = `
|
2026-04-06 16:18:07 -06:00
|
|
|
INSERT INTO jira_tickets (cve_id, vendor, ticket_key, url, summary, status, created_by)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
2026-02-09 11:56:34 -07:00
|
|
|
`;
|
|
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
db.run(query, [cve_id, vendor, ticket_key.trim(), url || null, summary || null, ticketStatus, req.user.id], function(err) {
|
2026-02-09 11:56:34 -07:00
|
|
|
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
|
2026-04-06 16:18:07 -06:00
|
|
|
app.put('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-09 11:56:34 -07:00
|
|
|
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
|
2026-04-06 16:18:07 -06:00
|
|
|
app.delete('/api/jira-tickets/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => {
|
2026-02-09 11:56:34 -07:00
|
|
|
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.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
// 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();
|
2026-02-09 11:56:34 -07:00
|
|
|
}
|
2026-04-06 16:18:07 -06:00
|
|
|
);
|
2026-02-09 11:56:34 -07:00
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
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.' });
|
|
|
|
|
}
|
2026-02-09 11:56:34 -07:00
|
|
|
|
2026-04-06 16:18:07 -06:00
|
|
|
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' });
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-09 11:56:34 -07:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
// Start server
|
|
|
|
|
app.listen(PORT, () => {
|
2026-01-28 09:23:30 -07:00
|
|
|
console.log(`CVE API server running on http://${API_HOST}:${PORT}`);
|
|
|
|
|
console.log(`CORS origins: ${CORS_ORIGINS.join(', ')}`);
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|