2026-01-27 04:06:03 +00:00
|
|
|
// CVE Management Backend API
|
2026-05-06 11:44:17 -06:00
|
|
|
// Install: npm install express pg 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 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-05-06 11:44:17 -06:00
|
|
|
// PostgreSQL connection pool
|
|
|
|
|
const pool = require('./db');
|
|
|
|
|
|
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 CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped
resolution logic, executive-level aggregated reporting, and drill-down
by vertical and metric. Supports daily upload cadence and batch commit.
Backend:
- Migration: add vertical column to compliance_items/uploads, create
vcl_multi_vertical_summary table
- New route module: routes/vclMultiVertical.js with preview, commit,
stats, trend, metric drill-down, device list, and burndown endpoints
- New helpers: parseVerticalFilename(), computeVerticalBurndown()
- Vertical-scoped resolution: uploading one vertical never resolves
items from other verticals
Frontend:
- CCPMetricsPage with stats bar, trend chart, donut, vertical table
- Drill-down: vertical -> metrics by category -> device list
- Per-vertical burndown forecast chart
- MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit
- Nav entry: CCP Metrics (Building2 icon)
Docs:
- Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
2026-05-14 09:49:59 -06:00
|
|
|
const { createVCLMultiVerticalRouter } = require('./routes/vclMultiVertical');
|
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-05-05 11:04:53 -06:00
|
|
|
const createFeedbackRouter = require('./routes/feedback');
|
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
|
|
|
|
Add CI/CD pipeline, feedback modal, Atlas qualys_id fallback, and health endpoint
- Rewrite .gitlab-ci.yml with proper stages, blocking tests, staging
environment on dev box, and SSH-based production deploy to 71.85.90.6
- Add POST /api/health endpoint for pipeline verification
- Add POST /atlas/hosts/:hostId/refresh-cache for Atlas cache staleness
- AtlasSlideOutPanel: auto-resolve qualys_id from Atlas vulnerabilities,
prefer qualys_id over active_host_findings_id, retry on failure
- Add FeedbackModal component with bug report button in header and
feature request in UserMenu, creates GitLab issues via /api/feedback
- Fix all frontend test failures (ESM transforms, TextDecoder polyfill,
fast-check resolution, App.test.js boilerplate replacement)
- Fix root package.json test script to run jest
- Add deploy/ directory with staging systemd service and setup script
2026-05-08 12:47:39 -06:00
|
|
|
// Health check endpoint (public — used by CI/CD pipeline verification)
|
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
|
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Auth routes (public)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/auth', createAuthRouter(logAudit));
|
2026-01-28 14:36:33 -07:00
|
|
|
|
|
|
|
|
// User management routes (admin only)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/users', createUsersRouter(requireAuth, requireGroup, logAudit));
|
2026-01-29 15:10:29 -07:00
|
|
|
|
|
|
|
|
// Audit log routes (admin only)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/audit-logs', createAuditLogRouter());
|
2026-01-28 14:36:33 -07:00
|
|
|
|
2026-02-02 10:50:38 -07:00
|
|
|
// NVD lookup routes (authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/nvd', createNvdLookupRouter());
|
2026-02-02 10:50:38 -07:00
|
|
|
|
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)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/knowledge-base', createKnowledgeBaseRouter(upload));
|
2026-02-13 09:43:09 -07:00
|
|
|
|
2026-02-18 15:02:25 -07:00
|
|
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/archer-tickets', createArcherTicketsRouter());
|
2026-02-18 15:02:25 -07:00
|
|
|
|
2026-03-10 15:29:33 -06:00
|
|
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter());
|
2026-03-10 15:29:33 -06:00
|
|
|
|
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)
|
2026-05-06 12:12:34 -06:00
|
|
|
// Pool imported directly inside the module; first arg kept for signature compat
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/ivanti/findings', createIvantiFindingsRouter(pool, 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
|
|
|
|
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
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/ivanti/todo-queue', createIvantiTodoQueueRouter());
|
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
|
|
|
|
2026-04-03 15:20:04 -06:00
|
|
|
// Ivanti archive routes — finding archive tracking for severity score drift
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/ivanti/archive', createIvantiArchiveRouter());
|
2026-04-03 15:20:04 -06:00
|
|
|
|
2026-04-07 16:20:24 -06:00
|
|
|
// Ivanti FP workflow routes — submit False Positive workflows to Ivanti API
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/ivanti/fp-workflow', createIvantiFpWorkflowRouter());
|
2026-04-07 16:20:24 -06:00
|
|
|
|
Add CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped
resolution logic, executive-level aggregated reporting, and drill-down
by vertical and metric. Supports daily upload cadence and batch commit.
Backend:
- Migration: add vertical column to compliance_items/uploads, create
vcl_multi_vertical_summary table
- New route module: routes/vclMultiVertical.js with preview, commit,
stats, trend, metric drill-down, device list, and burndown endpoints
- New helpers: parseVerticalFilename(), computeVerticalBurndown()
- Vertical-scoped resolution: uploading one vertical never resolves
items from other verticals
Frontend:
- CCPMetricsPage with stats bar, trend chart, donut, vertical table
- Drill-down: vertical -> metrics by category -> device list
- Per-vertical burndown forecast chart
- MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit
- Nav entry: CCP Metrics (Building2 icon)
Docs:
- Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
2026-05-14 09:49:59 -06:00
|
|
|
// VCL multi-vertical routes — cross-organizational compliance reporting
|
2026-05-14 10:15:15 -06:00
|
|
|
// Must be mounted BEFORE the general compliance router since both share the /api/compliance prefix
|
Add CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped
resolution logic, executive-level aggregated reporting, and drill-down
by vertical and metric. Supports daily upload cadence and batch commit.
Backend:
- Migration: add vertical column to compliance_items/uploads, create
vcl_multi_vertical_summary table
- New route module: routes/vclMultiVertical.js with preview, commit,
stats, trend, metric drill-down, device list, and burndown endpoints
- New helpers: parseVerticalFilename(), computeVerticalBurndown()
- Vertical-scoped resolution: uploading one vertical never resolves
items from other verticals
Frontend:
- CCPMetricsPage with stats bar, trend chart, donut, vertical table
- Drill-down: vertical -> metrics by category -> device list
- Per-vertical burndown forecast chart
- MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit
- Nav entry: CCP Metrics (Building2 icon)
Docs:
- Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
2026-05-14 09:49:59 -06:00
|
|
|
app.use('/api/compliance/vcl-multi', createVCLMultiVerticalRouter(upload));
|
|
|
|
|
|
2026-05-14 10:15:15 -06:00
|
|
|
// AEO compliance routes — xlsx upload, non-compliant item tracking, notes
|
|
|
|
|
app.use('/api/compliance', createComplianceRouter(upload));
|
|
|
|
|
|
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
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/atlas', createAtlasRouter());
|
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
|
|
|
|
2026-04-28 16:38:18 +00:00
|
|
|
// Jira ticket routes — local CRUD + Jira REST API integration (lookup, sync, create)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/jira-tickets', createJiraTicketsRouter());
|
2026-04-28 16:38:18 +00:00
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
// CARD Asset Ownership API routes — proxy CARD operations, mutation flow, asset search
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/card', createCardApiRouter());
|
2026-05-01 17:15:41 +00:00
|
|
|
|
2026-05-05 11:04:53 -06:00
|
|
|
// Feedback routes — bug reports and feature requests to GitLab
|
2026-05-06 11:44:17 -06:00
|
|
|
app.use('/api/feedback', createFeedbackRouter());
|
2026-05-05 11:04:53 -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)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves', requireAuth(), async (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { search, vendor, severity, status } = req.query;
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
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
|
|
|
|
|
`;
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
const params = [];
|
2026-05-06 11:44:17 -06:00
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
if (search) {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND (c.cve_id ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex + 1})`;
|
2026-01-27 04:06:03 +00:00
|
|
|
params.push(`%${search}%`, `%${search}%`);
|
2026-05-06 11:44:17 -06:00
|
|
|
paramIndex += 2;
|
2026-01-27 04:06:03 +00:00
|
|
|
}
|
|
|
|
|
if (vendor && vendor !== 'All Vendors') {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND c.vendor = $${paramIndex++}`;
|
2026-01-27 04:06:03 +00:00
|
|
|
params.push(vendor);
|
|
|
|
|
}
|
|
|
|
|
if (severity && severity !== 'All Severities') {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND c.severity = $${paramIndex++}`;
|
2026-01-27 04:06:03 +00:00
|
|
|
params.push(severity);
|
|
|
|
|
}
|
|
|
|
|
if (status) {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND c.status = $${paramIndex++}`;
|
2026-01-27 04:06:03 +00:00
|
|
|
params.push(status);
|
|
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
query += ` GROUP BY c.id ORDER BY c.published_date DESC`;
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(query, params);
|
2026-01-27 04:06:03 +00:00
|
|
|
res.json(rows);
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching CVEs:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-02 10:50:38 -07:00
|
|
|
// Get distinct CVE IDs for NVD sync (authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves/distinct-ids', requireAuth(), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT DISTINCT cve_id FROM cves ORDER BY cve_id');
|
2026-02-02 10:50:38 -07:00
|
|
|
res.json(rows.map(r => r.cve_id));
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-02-02 10:50:38 -07:00
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Check if CVE exists and get its status - UPDATED FOR MULTI-VENDOR (authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves/check/:cveId', requireAuth(), async (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { cveId } = req.params;
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-01-27 04:06:03 +00:00
|
|
|
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-05-06 11:44:17 -06:00
|
|
|
WHERE c.cve_id = $1
|
2026-01-27 04:06:03 +00:00
|
|
|
GROUP BY c.id
|
|
|
|
|
`;
|
2026-02-02 16:32:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(query, [cveId]);
|
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,
|
2026-05-06 11:44:17 -06:00
|
|
|
total_documents: parseInt(row.total_documents),
|
2026-02-02 16:32:44 -07:00
|
|
|
doc_types: {
|
2026-05-06 11:44:17 -06:00
|
|
|
email: parseInt(row.has_email) > 0,
|
|
|
|
|
screenshot: parseInt(row.has_screenshot) > 0
|
2026-01-27 23:00:12 +00:00
|
|
|
}
|
|
|
|
|
})),
|
2026-02-02 16:32:44 -07:00
|
|
|
addressed: true
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
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)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves/:cveId/vendors', requireAuth(), async (req, res) => {
|
2026-01-27 23:00:12 +00:00
|
|
|
const { cveId } = req.params;
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`SELECT vendor, severity, status, description, published_date
|
|
|
|
|
FROM cves
|
|
|
|
|
WHERE cve_id = $1
|
|
|
|
|
ORDER BY vendor`,
|
|
|
|
|
[cveId]
|
|
|
|
|
);
|
2026-01-27 23:00:12 +00:00
|
|
|
res.json(rows);
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 23:00:12 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 14:42:23 -06:00
|
|
|
// Get tooltip data for a specific CVE (authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves/:cveId/tooltip', requireAuth(), async (req, res) => {
|
2026-04-09 14:42:23 -06:00
|
|
|
const { cveId } = req.params;
|
|
|
|
|
|
|
|
|
|
if (!CVE_ID_PATTERN.test(cveId)) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid CVE ID format.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
'SELECT cve_id, description, severity FROM cves WHERE cve_id = $1 LIMIT 1',
|
|
|
|
|
[cveId]
|
|
|
|
|
);
|
|
|
|
|
const row = rows[0];
|
2026-04-09 14:42:23 -06:00
|
|
|
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-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching CVE tooltip:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-04-09 14:42:23 -06:00
|
|
|
});
|
2026-01-27 23:00:12 +00:00
|
|
|
|
2026-03-18 11:39:26 -06:00
|
|
|
// Compliance export — reads from cve_document_status view
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves/compliance', requireAuth(), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT * FROM cve_document_status ORDER BY cve_id, vendor');
|
2026-03-18 11:39:26 -06:00
|
|
|
res.json(rows);
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching compliance data:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-03-18 11:39:26 -06:00
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Create new CVE entry - ALLOW MULTIPLE VENDORS (editor or admin)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.post('/api/cves', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`INSERT INTO cves (cve_id, vendor, severity, description, published_date, created_by)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
[cve_id, vendor, severity, description, published_date, req.user.id]
|
|
|
|
|
);
|
2026-01-28 14:49:03 +00:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
2026-01-29 15:10:29 -07:00
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_create',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: cve_id,
|
|
|
|
|
details: { vendor, severity },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-01-29 15:10:29 -07:00
|
|
|
res.json({
|
2026-05-06 11:44:17 -06:00
|
|
|
id: rows[0].id,
|
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-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('DATABASE ERROR:', err);
|
|
|
|
|
if (err.code === '23505') { // Postgres unique violation
|
|
|
|
|
return res.status(409).json({
|
|
|
|
|
error: 'This CVE already exists for this vendor. Choose a different vendor or update the existing entry.'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
res.status(500).json({ error: 'Failed to create CVE entry.' });
|
|
|
|
|
}
|
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-05-06 11:44:17 -06:00
|
|
|
app.patch('/api/cves/:cveId/status', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'UPDATE cves SET status = $1, updated_at = NOW() WHERE cve_id = $2',
|
|
|
|
|
[status, cveId]
|
|
|
|
|
);
|
2026-01-29 15:10:29 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
2026-01-29 15:10:29 -07:00
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_update_status',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: cveId,
|
|
|
|
|
details: { status },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
res.json({ message: 'Status updated successfully', changes: result.rowCount });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
2026-02-02 10:50:38 -07:00
|
|
|
// Bulk sync CVE data from NVD (editor or admin)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.post('/api/cves/nvd-sync', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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 = [];
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
for (const entry of updates) {
|
|
|
|
|
const fields = [];
|
|
|
|
|
const values = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (entry.description !== null && entry.description !== undefined) {
|
|
|
|
|
fields.push(`description = $${paramIndex++}`);
|
|
|
|
|
values.push(entry.description);
|
|
|
|
|
}
|
|
|
|
|
if (entry.severity !== null && entry.severity !== undefined) {
|
|
|
|
|
fields.push(`severity = $${paramIndex++}`);
|
|
|
|
|
values.push(entry.severity);
|
|
|
|
|
}
|
|
|
|
|
if (entry.published_date !== null && entry.published_date !== undefined) {
|
|
|
|
|
fields.push(`published_date = $${paramIndex++}`);
|
|
|
|
|
values.push(entry.published_date);
|
|
|
|
|
}
|
|
|
|
|
if (fields.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
fields.push('updated_at = NOW()');
|
|
|
|
|
values.push(entry.cve_id);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`UPDATE cves SET ${fields.join(', ')} WHERE cve_id = $${paramIndex}`,
|
|
|
|
|
values
|
2026-02-02 10:50:38 -07:00
|
|
|
);
|
2026-05-06 11:44:17 -06:00
|
|
|
updated += result.rowCount;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('NVD sync update error:', err);
|
|
|
|
|
errors.push({ cve_id: entry.cve_id, error: 'Update failed' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_nvd_sync',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: null,
|
|
|
|
|
details: { count: updated, cve_ids: updates.map(u => u.cve_id) },
|
|
|
|
|
ipAddress: req.ip
|
2026-02-02 10:50:38 -07:00
|
|
|
});
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const result = { message: 'NVD sync completed', updated };
|
|
|
|
|
if (errors.length > 0) result.errors = errors;
|
|
|
|
|
res.json(result);
|
2026-02-02 10:50:38 -07:00
|
|
|
});
|
|
|
|
|
|
2026-02-02 11:33:44 -07:00
|
|
|
// ========== CVE EDIT & DELETE ENDPOINTS ==========
|
|
|
|
|
|
|
|
|
|
// Edit single CVE entry (editor or admin)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.put('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (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-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
// Fetch existing row first
|
|
|
|
|
const { rows: existingRows } = await pool.query('SELECT * FROM cves WHERE id = $1', [id]);
|
|
|
|
|
const existing = existingRows[0];
|
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;
|
|
|
|
|
|
|
|
|
|
if (cveIdChanged || vendorChanged) {
|
|
|
|
|
// Check UNIQUE constraint
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows: conflictRows } = await pool.query(
|
|
|
|
|
'SELECT id FROM cves WHERE cve_id = $1 AND vendor = $2 AND id != $3',
|
|
|
|
|
[newCveId, newVendor, id]
|
|
|
|
|
);
|
|
|
|
|
if (conflictRows.length > 0) {
|
|
|
|
|
return res.status(409).json({ error: 'A CVE entry with this CVE ID and vendor already exists.' });
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06: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));
|
2026-02-02 14:39:50 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
if (fs.existsSync(oldDir)) {
|
|
|
|
|
const newParent = path.join('uploads', newCveId);
|
|
|
|
|
if (!fs.existsSync(newParent)) {
|
|
|
|
|
fs.mkdirSync(newParent, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
fs.renameSync(oldDir, newDir);
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// 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);
|
2026-02-02 11:33:44 -07:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Update documents table - file paths
|
|
|
|
|
const { rows: docs } = await pool.query(
|
|
|
|
|
'SELECT id, file_path FROM documents WHERE cve_id = $1 AND vendor = $2',
|
|
|
|
|
[existing.cve_id, existing.vendor]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const oldPrefix = path.join('uploads', existing.cve_id, existing.vendor);
|
|
|
|
|
const newPrefix = path.join('uploads', newCveId, newVendor);
|
|
|
|
|
|
|
|
|
|
for (const doc of docs) {
|
|
|
|
|
const newFilePath = doc.file_path.replace(oldPrefix, newPrefix);
|
|
|
|
|
await pool.query(
|
|
|
|
|
'UPDATE documents SET cve_id = $1, vendor = $2, file_path = $3 WHERE id = $4',
|
|
|
|
|
[newCveId, newVendor, newFilePath, doc.id]
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
// Build dynamic SET clause
|
|
|
|
|
const fields = [];
|
|
|
|
|
const values = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
if (cve_id !== undefined) { fields.push(`cve_id = $${paramIndex++}`); values.push(cve_id); }
|
|
|
|
|
if (vendor !== undefined) { fields.push(`vendor = $${paramIndex++}`); values.push(vendor); }
|
|
|
|
|
if (severity !== undefined) { fields.push(`severity = $${paramIndex++}`); values.push(severity); }
|
|
|
|
|
if (description !== undefined) { fields.push(`description = $${paramIndex++}`); values.push(description); }
|
|
|
|
|
if (published_date !== undefined) { fields.push(`published_date = $${paramIndex++}`); values.push(published_date); }
|
|
|
|
|
if (status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(status); }
|
|
|
|
|
|
|
|
|
|
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
|
|
|
|
|
|
|
|
|
|
fields.push('updated_at = NOW()');
|
|
|
|
|
values.push(id);
|
|
|
|
|
|
|
|
|
|
const updateResult = await pool.query(
|
|
|
|
|
`UPDATE cves SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
|
|
|
|
values
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const after = {
|
|
|
|
|
cve_id: newCveId, vendor: newVendor,
|
|
|
|
|
severity: severity !== undefined ? severity : existing.severity,
|
|
|
|
|
description: description !== undefined ? description : existing.description,
|
|
|
|
|
published_date: published_date !== undefined ? published_date : existing.published_date,
|
|
|
|
|
status: status !== undefined ? status : existing.status
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_edit',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: newCveId,
|
|
|
|
|
details: { before, after },
|
|
|
|
|
ipAddress: req.ip
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ message: 'CVE updated successfully', changes: updateResult.rowCount });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Delete entire CVE - all vendors (editor or admin) - MUST be before /:id route
|
2026-05-06 11:44:17 -06:00
|
|
|
app.delete('/api/cves/by-cve-id/:cveId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-02-02 11:33:44 -07:00
|
|
|
const { cveId } = req.params;
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
// Get all rows for this CVE ID to know what we're deleting
|
|
|
|
|
const { rows } = await pool.query('SELECT * FROM cves WHERE cve_id = $1', [cveId]);
|
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
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows: archerTickets } = await pool.query(
|
|
|
|
|
'SELECT id, exc_number, cve_id, vendor FROM archer_tickets WHERE cve_id = $1',
|
|
|
|
|
[cveId]
|
|
|
|
|
);
|
2026-04-06 16:18:07 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
let jiraTickets = [];
|
|
|
|
|
try {
|
|
|
|
|
const jiraResult = await pool.query(
|
|
|
|
|
'SELECT id, cve_id, vendor, ticket_key, status FROM jira_tickets WHERE cve_id = $1',
|
|
|
|
|
[cveId]
|
|
|
|
|
);
|
|
|
|
|
jiraTickets = jiraResult.rows;
|
|
|
|
|
} catch (jiraErr) {
|
|
|
|
|
// If table doesn't exist yet, treat as empty
|
|
|
|
|
if (!jiraErr.message.includes('does not exist')) throw jiraErr;
|
|
|
|
|
}
|
2026-04-06 16:18:07 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows: docs } = await pool.query(
|
|
|
|
|
'SELECT id, name, type FROM documents WHERE cve_id = $1',
|
|
|
|
|
[cveId]
|
|
|
|
|
);
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Check compliance linkage for each ticket
|
|
|
|
|
const likeConditions = [];
|
|
|
|
|
const likeParams = [];
|
|
|
|
|
let pIdx = 1;
|
|
|
|
|
for (const t of allTickets) {
|
|
|
|
|
likeConditions.push(`ci.extra_json LIKE $${pIdx++}`);
|
|
|
|
|
likeParams.push(`%${t.key}%`);
|
|
|
|
|
}
|
|
|
|
|
// Also check if the CVE ID itself appears in compliance extra_json
|
|
|
|
|
likeConditions.push(`ci.extra_json LIKE $${pIdx++}`);
|
|
|
|
|
likeParams.push(`%${cveId}%`);
|
|
|
|
|
|
|
|
|
|
let compLinks = [];
|
|
|
|
|
try {
|
|
|
|
|
const compResult = await pool.query(
|
|
|
|
|
`SELECT ci.id, ci.extra_json, cu.report_date
|
|
|
|
|
FROM compliance_items ci
|
|
|
|
|
JOIN compliance_uploads cu ON ci.upload_id = cu.id
|
|
|
|
|
WHERE ci.status = 'active' AND (${likeConditions.join(' OR ')})`,
|
|
|
|
|
likeParams
|
|
|
|
|
);
|
|
|
|
|
compLinks = compResult.rows;
|
|
|
|
|
} catch (compErr) {
|
|
|
|
|
if (!compErr.message.includes('does not exist')) throw compErr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine which tickets are compliance-linked
|
|
|
|
|
const linkedTicketKeys = new Set();
|
|
|
|
|
for (const cl of compLinks) {
|
|
|
|
|
const json = cl.extra_json || '';
|
|
|
|
|
for (const t of allTickets) {
|
|
|
|
|
if (json.includes(t.key)) {
|
|
|
|
|
linkedTicketKeys.add(`${t.source}:${t.id}`);
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
if (json.includes(cveId)) {
|
|
|
|
|
for (const t of allTickets) {
|
|
|
|
|
linkedTicketKeys.add(`${t.source}:${t.id}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
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`
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Admin flow: proceed directly with deletion
|
|
|
|
|
await pool.query('DELETE FROM documents WHERE cve_id = $1', [cveId]);
|
|
|
|
|
const deleteResult = await pool.query('DELETE FROM cves WHERE cve_id = $1', [cveId]);
|
|
|
|
|
|
|
|
|
|
// Remove upload directory (with path traversal prevention)
|
|
|
|
|
const safeCveId = sanitizePathSegment(cveId);
|
|
|
|
|
const cveDir = path.join('uploads', safeCveId);
|
|
|
|
|
if (safeCveId && isPathWithinUploads(cveDir) && fs.existsSync(cveDir)) {
|
|
|
|
|
fs.rmSync(cveDir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_delete',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: cveId,
|
|
|
|
|
details: { type: 'all_vendors', vendors: rows.map(r => r.vendor), count: rows.length },
|
|
|
|
|
ipAddress: req.ip
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
res.json({ message: `Deleted CVE ${cveId} and all ${rows.length} vendor entries`, deleted: deleteResult.rowCount });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-02-02 11:33:44 -07:00
|
|
|
// Delete single CVE vendor entry (editor or admin)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.delete('/api/cves/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-02-02 11:33:44 -07:00
|
|
|
const { id } = req.params;
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows: cveRows } = await pool.query('SELECT * FROM cves WHERE id = $1', [id]);
|
|
|
|
|
const cve = cveRows[0];
|
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') {
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows: archerTickets } = await pool.query(
|
|
|
|
|
'SELECT id, exc_number FROM archer_tickets WHERE cve_id = $1 AND vendor = $2',
|
|
|
|
|
[cve.cve_id, cve.vendor]
|
|
|
|
|
);
|
2026-04-07 09:52:26 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
let jiraTickets = [];
|
|
|
|
|
try {
|
|
|
|
|
const jiraResult = await pool.query(
|
|
|
|
|
'SELECT id, ticket_key FROM jira_tickets WHERE cve_id = $1 AND vendor = $2',
|
|
|
|
|
[cve.cve_id, cve.vendor]
|
|
|
|
|
);
|
|
|
|
|
jiraTickets = jiraResult.rows;
|
|
|
|
|
} catch (jiraErr) {
|
|
|
|
|
if (!jiraErr.message.includes('does not exist')) throw jiraErr;
|
|
|
|
|
}
|
2026-04-07 09:52:26 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const allTickets = [
|
|
|
|
|
...(archerTickets || []).map(t => ({ ...t, source: 'archer', key: t.exc_number })),
|
|
|
|
|
...(jiraTickets || []).map(t => ({ ...t, source: 'jira', key: t.ticket_key }))
|
|
|
|
|
];
|
2026-04-07 09:52:26 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
if (allTickets.length > 0) {
|
|
|
|
|
const likeConditions = allTickets.map((_, i) => `ci.extra_json LIKE $${i + 1}`);
|
|
|
|
|
const likeParams = allTickets.map(t => `%${t.key}%`);
|
2026-04-07 09:52:26 -06:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
let compLinks = [];
|
|
|
|
|
try {
|
|
|
|
|
const compResult = await pool.query(
|
2026-04-07 09:52:26 -06:00
|
|
|
`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 ')})`,
|
2026-05-06 11:44:17 -06:00
|
|
|
likeParams
|
2026-04-07 09:52:26 -06:00
|
|
|
);
|
2026-05-06 11:44:17 -06:00
|
|
|
compLinks = compResult.rows;
|
|
|
|
|
} catch (compErr) {
|
|
|
|
|
if (!compErr.message.includes('does not exist')) throw compErr;
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
const hasLink = compLinks.some(cl => {
|
|
|
|
|
const json = cl.extra_json || '';
|
|
|
|
|
return allTickets.some(t => json.includes(t.key));
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
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' }
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Proceed with deletion
|
|
|
|
|
// Delete associated documents from DB and disk
|
|
|
|
|
const { rows: docs } = await pool.query(
|
|
|
|
|
'SELECT id, file_path FROM documents WHERE cve_id = $1 AND vendor = $2',
|
|
|
|
|
[cve.cve_id, cve.vendor]
|
|
|
|
|
);
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Delete document files from disk (with path traversal prevention)
|
|
|
|
|
if (docs && docs.length > 0) {
|
|
|
|
|
docs.forEach(doc => {
|
|
|
|
|
if (doc.file_path && isPathWithinUploads(doc.file_path) && fs.existsSync(doc.file_path)) {
|
|
|
|
|
fs.unlinkSync(doc.file_path);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Delete documents from DB
|
|
|
|
|
await pool.query('DELETE FROM documents WHERE cve_id = $1 AND vendor = $2', [cve.cve_id, cve.vendor]);
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
// Delete CVE row
|
|
|
|
|
const deleteResult = await pool.query('DELETE FROM cves WHERE id = $1', [id]);
|
2026-02-02 11:33:44 -07:00
|
|
|
|
2026-05-06 11:44:17 -06: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 });
|
|
|
|
|
}
|
|
|
|
|
const safeCveDir = path.join('uploads', sanitizePathSegment(cve.cve_id));
|
|
|
|
|
if (isPathWithinUploads(safeCveDir) && fs.existsSync(safeCveDir)) {
|
|
|
|
|
const remaining = fs.readdirSync(safeCveDir);
|
|
|
|
|
if (remaining.length === 0) fs.rmdirSync(safeCveDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'cve_delete',
|
|
|
|
|
entityType: 'cve',
|
|
|
|
|
entityId: cve.cve_id,
|
|
|
|
|
details: { type: 'single_vendor', vendor: cve.vendor, severity: cve.severity },
|
|
|
|
|
ipAddress: req.ip
|
2026-02-02 11:33:44 -07:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
res.json({ message: `Deleted ${cve.vendor} entry for ${cve.cve_id}` });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
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)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/cves/:cveId/documents', requireAuth(), async (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { cveId } = req.params;
|
2026-05-06 11:44:17 -06:00
|
|
|
const { vendor } = req.query; // Optional vendor filter
|
|
|
|
|
|
|
|
|
|
let query = 'SELECT * FROM documents WHERE cve_id = $1';
|
|
|
|
|
const params = [cveId];
|
|
|
|
|
let paramIndex = 2;
|
|
|
|
|
|
2026-01-27 23:00:12 +00:00
|
|
|
if (vendor) {
|
2026-05-06 11:44:17 -06:00
|
|
|
query += ` AND vendor = $${paramIndex++}`;
|
2026-01-27 23:00:12 +00:00
|
|
|
params.push(vendor);
|
|
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
query += ' ORDER BY uploaded_at DESC';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(query, params);
|
2026-01-27 04:06:03 +00:00
|
|
|
res.json(rows);
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Upload document - ADD ERROR HANDLING FOR MULTER (editor or admin)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.post('/api/cves/:cveId/documents', requireAuth(), requireGroup('Admin', 'Standard_User'), (req, res, next) => {
|
|
|
|
|
upload.single('file')(req, res, async (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);
|
|
|
|
|
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-05-06 11:44:17 -06: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
|
|
|
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 fileSizeKB = (file.size / 1024).toFixed(2) + ' KB';
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
`INSERT INTO documents (cve_id, vendor, name, type, file_path, file_size, mime_type, notes)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
|
|
|
RETURNING id`,
|
|
|
|
|
[cveId, vendor, file.originalname, type, finalPath, fileSizeKB, file.mimetype, notes]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
logAudit({
|
2026-01-29 15:10:29 -07:00
|
|
|
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-05-06 11:44:17 -06:00
|
|
|
|
2026-01-28 14:49:03 +00:00
|
|
|
res.json({
|
2026-05-06 11:44:17 -06:00
|
|
|
id: rows[0].id,
|
2026-01-28 14:49:03 +00:00
|
|
|
message: 'Document uploaded successfully',
|
|
|
|
|
file: {
|
|
|
|
|
name: file.originalname,
|
|
|
|
|
size: fileSizeKB
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (dbErr) {
|
|
|
|
|
console.error('Document insert error:', dbErr);
|
|
|
|
|
// If database insert fails, delete the file
|
|
|
|
|
if (fs.existsSync(finalPath)) {
|
|
|
|
|
fs.unlinkSync(finalPath);
|
|
|
|
|
}
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Delete document (admin only)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.delete('/api/documents/:id', requireAuth(), requireGroup('Admin'), async (req, res) => {
|
2026-01-27 04:06:03 +00:00
|
|
|
const { id } = req.params;
|
2026-02-02 14:39:50 -07:00
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
// First get the file path to delete the actual file
|
|
|
|
|
const { rows } = await pool.query('SELECT file_path FROM documents WHERE id = $1', [id]);
|
|
|
|
|
const row = rows[0];
|
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);
|
|
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
await pool.query('DELETE FROM documents WHERE id = $1', [id]);
|
|
|
|
|
|
|
|
|
|
logAudit({
|
|
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'document_delete',
|
|
|
|
|
entityType: 'document',
|
|
|
|
|
entityId: id,
|
|
|
|
|
details: { file_path: row ? row.file_path : null },
|
|
|
|
|
ipAddress: req.ip
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
2026-05-06 11:44:17 -06:00
|
|
|
|
|
|
|
|
res.json({ message: 'Document deleted successfully' });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ========== UTILITY ENDPOINTS ==========
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Get all vendors (authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/vendors', requireAuth(), async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query('SELECT DISTINCT vendor FROM cves ORDER BY vendor');
|
2026-01-27 04:06:03 +00:00
|
|
|
res.json(rows.map(r => r.vendor));
|
2026-05-06 11:44:17 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-28 14:36:33 -07:00
|
|
|
// Get statistics (authenticated users)
|
2026-05-06 11:44:17 -06:00
|
|
|
app.get('/api/stats', requireAuth(), async (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-05-06 11:44:17 -06:00
|
|
|
try {
|
|
|
|
|
const { rows } = await pool.query(query);
|
|
|
|
|
res.json(rows[0]);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.' });
|
|
|
|
|
}
|
2026-01-27 04:06:03 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-06 13:38:38 -06:00
|
|
|
// Serve frontend build (for testing/production — serves React SPA)
|
|
|
|
|
const frontendBuild = path.join(__dirname, '..', 'frontend', 'build');
|
|
|
|
|
if (fs.existsSync(frontendBuild)) {
|
|
|
|
|
app.use(express.static(frontendBuild));
|
|
|
|
|
// SPA fallback — serve index.html for any non-API route
|
|
|
|
|
app.use((req, res, next) => {
|
|
|
|
|
if (!req.path.startsWith('/api/') && !req.path.startsWith('/uploads/')) {
|
|
|
|
|
res.sendFile(path.join(frontendBuild, 'index.html'));
|
|
|
|
|
} else {
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
});
|