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');
|
2026-05-01 17:15:41 +00:00
|
|
|
const { createComplianceRouter } = require('./routes/compliance');
|
Add Atlas InfoSec action plans integration
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
2026-04-23 21:52:53 +00:00
|
|
|
const createAtlasRouter = require('./routes/atlas');
|
2026-04-28 16:38:18 +00:00
|
|
|
const createJiraTicketsRouter = require('./routes/jiraTickets');
|
2026-05-01 17:15:41 +00:00
|
|
|
const createCardApiRouter = require('./routes/cardApi');
|
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
|
|
|
|
Add Atlas InfoSec action plans integration
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
2026-04-23 21:52:53 +00:00
|
|
|
// Atlas InfoSec action plan routes — proxy CRUD to Atlas API, local cache for badges
|
|
|
|
|
app.use('/api/atlas', createAtlasRouter(db, requireAuth));
|
|
|
|
|
|
2026-04-28 16:38:18 +00:00
|
|
|
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
|
|
|
|
|
app.use('/api/jira-tickets', createJiraTicketsRouter(db));
|
|
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
|
|
|
|
|
app.use('/api/card', createCardApiRouter(db, requireAuth));
|
|
|
|
|
|
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-04-09 14:42:23 -06:00
|
|
|
// 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 });
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-01-27 23:00:12 +00:00
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
});
|