From 1a6ae8309d66ced072c4ab8d5802bbc23f1c03b8 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 23 Apr 2026 22:50:37 -0400 Subject: [PATCH] refactor(backend): extract security router (Trivy, scans, SBOM, policies, suppressions, compare) (phase 4c-3) (#741) Move the entire /api/security/* surface out of index.ts: - Trivy lifecycle: status, install, uninstall, update-check, update, auto-update toggle - Scanning: POST /scan (image), POST /scan/stack (compose) - Scan queries: list, get, vulnerabilities, secrets, misconfigs, image-summaries, SARIF export - SBOM generation - Scan policies CRUD (fleet-replicated; replica writes rejected) - CVE suppressions CRUD (fleet-replicated; replica writes rejected) - GET /compare: diff two scans Handlers moved verbatim. Local CVE_ID_RE, parseScannersInput, and shapeScanForResponse helpers move with the router. The previously-closure fetchAll inside the SARIF handler is hoisted to module scope as fetchAllPages so it is not re-allocated per request. Removes nine now-unused imports from index.ts (parsePolicyEvaluation, VulnerabilityScan, FleetSyncService, requireAdmiral, trivyInstallLimiter, SbomFormat, TrivyInstaller, validateImageRef, applySuppressions, generateSarif). index.ts drops from 2130 to 1437 lines. Remaining inline groups: containers, stacks, and nodes. --- backend/src/index.ts | 705 +-------------------------------- backend/src/routes/security.ts | 699 ++++++++++++++++++++++++++++++++ 2 files changed, 705 insertions(+), 699 deletions(-) create mode 100644 backend/src/routes/security.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 8f3ad280..f62c16bf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,7 +5,7 @@ import { FileSystemService } from './services/FileSystemService'; import { ComposeService } from './services/ComposeService'; import crypto from 'crypto'; import path from 'path'; -import { DatabaseService, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService'; +import { DatabaseService } from './services/DatabaseService'; import { MonitorService } from './services/MonitorService'; import { AutoHealService } from './services/AutoHealService'; import { DockerEventManager } from './services/DockerEventManager'; @@ -14,7 +14,6 @@ import { UpdatePreviewService } from './services/UpdatePreviewService'; import { NodeRegistry } from './services/NodeRegistry'; import { PilotTunnelManager } from './services/PilotTunnelManager'; import { PilotCloseCode } from './pilot/protocol'; -import { FleetSyncService } from './services/FleetSyncService'; import { LicenseService } from './services/LicenseService'; import { SchedulerService } from './services/SchedulerService'; import { CacheService } from './services/CacheService'; @@ -31,7 +30,6 @@ import { import { requirePermission } from './middleware/permissions'; import { requirePaid, - requireAdmiral, requireAdmin, } from './middleware/tierGates'; import { @@ -39,7 +37,6 @@ import { runPolicyGate, triggerPostDeployScan, } from './helpers/policyGate'; -import { trivyInstallLimiter } from './middleware/rateLimiters'; import { authGate, auditLog } from './middleware/authGate'; import { enforceApiTokenScope } from './middleware/apiTokenScope'; import { errorHandler } from './middleware/errorHandler'; @@ -78,16 +75,13 @@ import { ssoConfigRouter } from './routes/ssoConfig'; import { registriesRouter } from './routes/registries'; import { systemMaintenanceRouter } from './routes/systemMaintenance'; import { templatesRouter } from './routes/templates'; +import { securityRouter } from './routes/security'; import { isDebugEnabled } from './utils/debug'; import { getErrorMessage } from './utils/errors'; import SelfUpdateService from './services/SelfUpdateService'; -import TrivyService, { SbomFormat } from './services/TrivyService'; -import TrivyInstaller from './services/TrivyInstaller'; +import TrivyService from './services/TrivyService'; import { enforcePolicyPreDeploy } from './services/PolicyEnforcement'; -import { validateImageRef } from './utils/image-ref'; -import { applySuppressions } from './utils/suppression-filter'; -import { generateSarif } from './services/SarifExporter'; import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation'; import YAML from 'yaml'; @@ -162,22 +156,11 @@ app.use('/api/sso/config', ssoConfigRouter); app.use('/api/registries', registriesRouter); app.use('/api/system', systemMaintenanceRouter); app.use('/api/templates', templatesRouter); +app.use('/api/security', securityRouter); -// Symbols still consumed by inline security and node routes still living in -// index.ts. These will move with their route groups in a later slice. +// Symbol still consumed by inline node routes still living in index.ts. +// Will move with the node route group in a later slice. const updateTracker = FleetUpdateTrackerService.getInstance(); -const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/; - -function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | undefined | null { - if (raw === undefined || raw === null) return undefined; - if (!Array.isArray(raw) || raw.length === 0) return null; - const out = new Set<'vuln' | 'secret'>(); - for (const item of raw) { - if (item !== 'vuln' && item !== 'secret') return null; - out.add(item); - } - return Array.from(out) as readonly ('vuln' | 'secret')[]; -} // Remote Node HTTP Proxy (see proxy/remoteNodeProxy.ts). Mounted here after // authGate + auditLog + apiTokenScope so local Sencho enforces auth first; @@ -965,682 +948,6 @@ app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => } }); -// ========================= -// Vulnerability Scanning Routes -// ========================= - -app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => { - const svc = TrivyService.getInstance(); - const installer = TrivyInstaller.getInstance(); - const settings = DatabaseService.getInstance().getGlobalSettings(); - res.json({ - available: svc.isTrivyAvailable(), - version: svc.getVersion(), - source: svc.getSource(), - autoUpdate: settings.trivy_auto_update === '1', - busy: installer.isBusy(), - }); -}); - -app.post('/api/security/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmiral(req, res)) return; - const svc = TrivyService.getInstance(); - if (svc.getSource() === 'host') { - res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' }); - return; - } - if (svc.getSource() === 'managed') { - res.status(409).json({ error: 'Trivy is already installed. Use the update endpoint instead.' }); - return; - } - try { - const { version } = await TrivyInstaller.getInstance().install(); - await svc.detectTrivy(); - res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() }); - } catch (err) { - const msg = getErrorMessage(err, 'Install failed'); - console.error('[Security] Trivy install failed:', msg); - res.status(500).json({ error: msg }); - } -}); - -app.delete('/api/security/trivy-install', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmiral(req, res)) return; - const svc = TrivyService.getInstance(); - if (svc.getSource() !== 'managed') { - res.status(409).json({ error: 'No managed Trivy install to remove' }); - return; - } - try { - await TrivyInstaller.getInstance().uninstall(); - await svc.detectTrivy(); - res.json({ available: svc.isTrivyAvailable(), source: svc.getSource() }); - } catch (err) { - const msg = getErrorMessage(err, 'Uninstall failed'); - console.error('[Security] Trivy uninstall failed:', msg); - res.status(500).json({ error: msg }); - } -}); - -app.get('/api/security/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmiral(req, res)) return; - const svc = TrivyService.getInstance(); - if (svc.getSource() !== 'managed') { - res.status(409).json({ error: 'Update checks only apply to managed installs' }); - return; - } - try { - const result = await TrivyInstaller.getInstance().checkForUpdate(svc.getVersion(), svc.getSource()); - res.json(result); - } catch (err) { - const msg = getErrorMessage(err, 'Update check failed'); - console.error('[Security] Trivy update check failed:', msg); - res.status(502).json({ error: msg }); - } -}); - -app.post('/api/security/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmiral(req, res)) return; - const svc = TrivyService.getInstance(); - if (svc.getSource() !== 'managed') { - res.status(409).json({ error: 'Update only applies to managed installs' }); - return; - } - try { - const { version } = await TrivyInstaller.getInstance().update(); - await svc.detectTrivy(); - res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() }); - } catch (err) { - const msg = getErrorMessage(err, 'Update failed'); - console.error('[Security] Trivy update failed:', msg); - res.status(500).json({ error: msg }); - } -}); - -app.put('/api/security/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmiral(req, res)) return; - const enabled = req.body?.enabled === true; - try { - DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', enabled ? '1' : '0'); - res.json({ autoUpdate: enabled }); - } catch (err) { - const msg = getErrorMessage(err, 'Failed to update setting'); - console.error('[Security] Trivy auto-update toggle failed:', msg); - res.status(500).json({ error: msg }); - } -}); - -app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - const svc = TrivyService.getInstance(); - if (!svc.isTrivyAvailable()) { - res.status(503).json({ error: 'Trivy is not available on this host' }); - return; - } - const rawImageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : ''; - if (!rawImageRef) { - res.status(400).json({ error: 'imageRef is required' }); - return; - } - if (!validateImageRef(rawImageRef)) { - res.status(400).json({ error: 'Invalid imageRef format' }); - return; - } - const imageRef = rawImageRef; - const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null; - const force = req.body?.force === true; - const scanners = parseScannersInput(req.body?.scanners); - if (scanners === null) { - res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' }); - return; - } - if (scanners?.includes('secret') && !requirePaid(req, res)) return; - const nodeId = req.nodeId; - if (svc.isScanning(nodeId, imageRef)) { - res.status(409).json({ error: 'Already scanning this image' }); - return; - } - const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext, scanners); - res.status(202).json({ scanId }); - - svc.finishScan(scanId, imageRef, nodeId, { useCache: !force, scanners }).catch((err) => { - console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message); - }); -}); - -app.post('/api/security/scan/stack', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - const svc = TrivyService.getInstance(); - if (!svc.isTrivyAvailable()) { - res.status(503).json({ error: 'Trivy is not available on this host' }); return; - } - const stackName = typeof req.body?.stackName === 'string' ? req.body.stackName.trim() : ''; - if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { - res.status(400).json({ error: 'Invalid stack name' }); return; - } - try { - const scan = await svc.scanComposeStack(req.nodeId, stackName, 'manual'); - res.status(201).json(scan); - } catch (error) { - const message = (error as Error).message || ''; - if (message === 'Invalid stack path' || message.startsWith('No compose file found')) { - res.status(404).json({ error: message }); return; - } - console.error('[Security] Stack config scan failed:', error); - res.status(500).json({ error: message || 'Failed to scan stack' }); - } -}); - -function shapeScanForResponse(scan: VulnerabilityScan): Omit & { - policy_evaluation: ReturnType; -} { - const { policy_evaluation, ...rest } = scan; - return { ...rest, policy_evaluation: parsePolicyEvaluation(policy_evaluation) }; -} - -app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => { - try { - const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined; - const imageRefLike = - typeof req.query.imageRefLike === 'string' && req.query.imageRefLike.trim() - ? req.query.imageRefLike.trim() - : undefined; - const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined; - const status = - statusParam === 'completed' || statusParam === 'in_progress' || statusParam === 'failed' - ? statusParam - : undefined; - const limit = req.query.limit ? Number(req.query.limit) : undefined; - const offset = req.query.offset ? Number(req.query.offset) : undefined; - const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, { - imageRef, - imageRefLike, - status, - limit, - offset, - }); - res.json({ ...result, items: result.items.map(shapeScanForResponse) }); - } catch (error) { - console.error('[Security] Failed to list scans:', error); - res.status(500).json({ error: 'Failed to list scans' }); - } -}); - -app.get('/api/security/scans/:scanId', authMiddleware, (req: Request, res: Response): void => { - const scanId = Number(req.params.scanId); - if (!Number.isFinite(scanId)) { - res.status(400).json({ error: 'Invalid scan id' }); return; - } - const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId); - if (!scan || scan.node_id !== req.nodeId) { - res.status(404).json({ error: 'Scan not found' }); return; - } - res.json(shapeScanForResponse(scan)); -}); - -app.get( - '/api/security/scans/:scanId/vulnerabilities', - authMiddleware, - (req: Request, res: Response): void => { - const scanId = Number(req.params.scanId); - if (!Number.isFinite(scanId)) { - res.status(400).json({ error: 'Invalid scan id' }); return; - } - const db = DatabaseService.getInstance(); - const scan = db.getVulnerabilityScan(scanId); - if (!scan || scan.node_id !== req.nodeId) { - res.status(404).json({ error: 'Scan not found' }); return; - } - const severity = typeof req.query.severity === 'string' - ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') - : undefined; - const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); - if (severity && !validSeverities.has(severity)) { - res.status(400).json({ error: 'Invalid severity filter' }); return; - } - const limit = req.query.limit ? Number(req.query.limit) : undefined; - const offset = req.query.offset ? Number(req.query.offset) : undefined; - const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset }); - const suppressions = db.getCveSuppressions(); - const enriched = applySuppressions(result.items, scan.image_ref, suppressions); - res.json({ ...result, items: enriched }); - }, -); - -app.get( - '/api/security/scans/:scanId/secrets', - authMiddleware, - (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; - const scanId = Number(req.params.scanId); - if (!Number.isFinite(scanId)) { - res.status(400).json({ error: 'Invalid scan id' }); return; - } - const db = DatabaseService.getInstance(); - const scan = db.getVulnerabilityScan(scanId); - if (!scan || scan.node_id !== req.nodeId) { - res.status(404).json({ error: 'Scan not found' }); return; - } - const severity = typeof req.query.severity === 'string' - ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') - : undefined; - const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); - if (severity && !validSeverities.has(severity)) { - res.status(400).json({ error: 'Invalid severity filter' }); return; - } - const limit = req.query.limit ? Number(req.query.limit) : undefined; - const offset = req.query.offset ? Number(req.query.offset) : undefined; - res.json(db.getSecretFindings(scanId, { severity, limit, offset })); - }, -); - -app.get( - '/api/security/scans/:scanId/misconfigs', - authMiddleware, - (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; - const scanId = Number(req.params.scanId); - if (!Number.isFinite(scanId)) { - res.status(400).json({ error: 'Invalid scan id' }); return; - } - const db = DatabaseService.getInstance(); - const scan = db.getVulnerabilityScan(scanId); - if (!scan || scan.node_id !== req.nodeId) { - res.status(404).json({ error: 'Scan not found' }); return; - } - const severity = typeof req.query.severity === 'string' - ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') - : undefined; - const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); - if (severity && !validSeverities.has(severity)) { - res.status(400).json({ error: 'Invalid severity filter' }); return; - } - const limit = req.query.limit ? Number(req.query.limit) : undefined; - const offset = req.query.offset ? Number(req.query.offset) : undefined; - res.json(db.getMisconfigFindings(scanId, { severity, limit, offset })); - }, -); - -app.get('/api/security/image-summaries', authMiddleware, (req: Request, res: Response) => { - try { - const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId); - res.json(summaries); - } catch (error) { - console.error('[Security] Failed to fetch image summaries:', error); - res.status(500).json({ error: 'Failed to fetch image summaries' }); - } -}); - -app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Response): Promise => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - const svc = TrivyService.getInstance(); - if (!svc.isTrivyAvailable()) { - res.status(503).json({ error: 'Trivy is not available on this host' }); return; - } - const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : ''; - const formatRaw = typeof req.body?.format === 'string' ? req.body.format : 'spdx-json'; - if (!imageRef) { - res.status(400).json({ error: 'imageRef is required' }); return; - } - if (!validateImageRef(imageRef)) { - res.status(400).json({ error: 'Invalid imageRef format' }); return; - } - if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') { - res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return; - } - try { - const sbom = await svc.generateSBOM(imageRef, formatRaw as SbomFormat); - const safeName = imageRef.replace(/[^a-zA-Z0-9._-]/g, '_'); - const ext = formatRaw === 'spdx-json' ? 'spdx.json' : 'cdx.json'; - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${ext}"`); - res.send(sbom); - } catch (error) { - console.error('[Security] SBOM generation failed:', error); - res.status(500).json({ error: (error as Error).message || 'Failed to generate SBOM' }); - } -}); - -app.get( - '/api/security/scans/:scanId/sarif', - authMiddleware, - (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - const scanId = Number(req.params.scanId); - if (!Number.isFinite(scanId)) { - res.status(400).json({ error: 'Invalid scan id' }); return; - } - const db = DatabaseService.getInstance(); - const scan = db.getVulnerabilityScan(scanId); - if (!scan || scan.node_id !== req.nodeId) { - res.status(404).json({ error: 'Scan not found' }); return; - } - if (scan.status !== 'completed') { - res.status(409).json({ error: 'Scan not complete' }); return; - } - const fetchAll = ( - q: (opts: { limit?: number; offset?: number }) => { items: T[]; total: number }, - ): T[] => { - const pageSize = 1000; - const collected: T[] = []; - let offset = 0; - while (true) { - const page = q({ limit: pageSize, offset }); - collected.push(...page.items); - if (collected.length >= page.total || page.items.length === 0) break; - offset += page.items.length; - } - return collected; - }; - try { - const details = fetchAll((opts) => db.getVulnerabilityDetails(scanId, opts)); - const secrets = fetchAll((opts) => db.getSecretFindings(scanId, opts)); - const misconfigs = fetchAll((opts) => db.getMisconfigFindings(scanId, opts)); - const suppressed = applySuppressions(details, scan.image_ref, db.getCveSuppressions()); - const sarif = generateSarif(scan, suppressed, secrets, misconfigs); - const safeName = scan.image_ref.replace(/[^a-zA-Z0-9._-]/g, '_') || `scan-${scanId}`; - res.setHeader('Content-Type', 'application/sarif+json'); - res.setHeader('Content-Disposition', `attachment; filename="${safeName}.sarif.json"`); - res.send(JSON.stringify(sarif)); - } catch (error) { - console.error('[Security] SARIF export failed:', error); - res.status(500).json({ error: (error as Error).message || 'Failed to generate SARIF' }); - } - }, -); - -app.get('/api/security/policies', authMiddleware, (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; - res.json(DatabaseService.getInstance().getScanPolicies()); -}); - -app.post('/api/security/policies', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'Security policies are managed from the control node.' }); - return; - } - const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {}; - if (!name || typeof name !== 'string' || !name.trim()) { - res.status(400).json({ error: 'Policy name is required' }); return; - } - const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); - if (!validSeverities.has(max_severity)) { - res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return; - } - try { - const resolvedNodeId = node_id != null ? Number(node_id) : null; - const policy = DatabaseService.getInstance().createScanPolicy({ - name: name.trim(), - node_id: resolvedNodeId, - node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId), - stack_pattern: stack_pattern ? String(stack_pattern) : null, - max_severity, - block_on_deploy: block_on_deploy ? 1 : 0, - enabled: enabled === false ? 0 : 1, - replicated_from_control: 0, - }); - FleetSyncService.getInstance().pushResourceAsync('scan_policies'); - res.status(201).json(policy); - } catch (error) { - console.error('[Security] Failed to create policy:', error); - res.status(500).json({ error: 'Failed to create policy' }); - } -}); - -app.put('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'Security policies are managed from the control node.' }); - return; - } - const id = Number(req.params.id); - if (!Number.isFinite(id)) { - res.status(400).json({ error: 'Invalid policy id' }); return; - } - const body = req.body ?? {}; - const updates: Record = {}; - if (body.name !== undefined) updates.name = String(body.name).trim(); - if (body.node_id !== undefined) { - const resolvedNodeId = body.node_id != null ? Number(body.node_id) : null; - updates.node_id = resolvedNodeId; - updates.node_identity = FleetSyncService.resolveIdentityForNodeId(resolvedNodeId); - } - if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null; - if (body.max_severity !== undefined) { - const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); - if (!validSeverities.has(body.max_severity)) { - res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return; - } - updates.max_severity = body.max_severity; - } - if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0; - if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0; - const policy = DatabaseService.getInstance().updateScanPolicy(id, updates); - if (!policy) { - res.status(404).json({ error: 'Policy not found' }); return; - } - FleetSyncService.getInstance().pushResourceAsync('scan_policies'); - res.json(policy); -}); - -app.delete('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'Security policies are managed from the control node.' }); - return; - } - const id = Number(req.params.id); - if (!Number.isFinite(id)) { - res.status(400).json({ error: 'Invalid policy id' }); return; - } - DatabaseService.getInstance().deleteScanPolicy(id); - FleetSyncService.getInstance().pushResourceAsync('scan_policies'); - res.json({ success: true }); -}); - -// CVE suppressions. Rules live on the control instance and replicate fleet-wide. -// Reads are open to any authenticated user so operators on replicas can audit; writes -// are admin-only and rejected on replicas. - -app.get('/api/security/suppressions', authMiddleware, (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; - const now = Date.now(); - const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({ - ...s, - active: s.expires_at === null || s.expires_at > now, - })); - res.json(rows); -}); - -app.post('/api/security/suppressions', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); - return; - } - const body = req.body ?? {}; - const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; - if (!CVE_ID_RE.test(cveId)) { - res.status(400).json({ error: 'cve_id must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx' }); - return; - } - const pkgName = body.pkg_name == null || body.pkg_name === '' ? null : String(body.pkg_name).trim(); - if (pkgName !== null && pkgName.length > 200) { - res.status(400).json({ error: 'pkg_name is too long' }); return; - } - const imagePattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim(); - if (imagePattern !== null && imagePattern.length > 300) { - res.status(400).json({ error: 'image_pattern is too long' }); return; - } - const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; - if (!reason) { - res.status(400).json({ error: 'reason is required' }); return; - } - if (reason.length > 2000) { - res.status(400).json({ error: 'reason is too long' }); return; - } - const expiresAt = body.expires_at == null ? null : Number(body.expires_at); - if (expiresAt !== null && !Number.isFinite(expiresAt)) { - res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return; - } - try { - const suppression = DatabaseService.getInstance().createCveSuppression({ - cve_id: cveId, - pkg_name: pkgName, - image_pattern: imagePattern, - reason, - created_by: req.user?.username || 'unknown', - created_at: Date.now(), - expires_at: expiresAt, - replicated_from_control: 0, - }); - FleetSyncService.getInstance().pushResourceAsync('cve_suppressions'); - res.status(201).json(suppression); - } catch (error) { - const message = (error as Error).message || ''; - if (message.includes('UNIQUE')) { - res.status(409).json({ error: 'A suppression already exists for this CVE, package, and image pattern.' }); - return; - } - console.error('[Security] Failed to create suppression:', error); - res.status(500).json({ error: 'Failed to create suppression' }); - } -}); - -app.put('/api/security/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); - return; - } - const id = Number(req.params.id); - if (!Number.isFinite(id)) { - res.status(400).json({ error: 'Invalid suppression id' }); return; - } - const body = req.body ?? {}; - const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null }> = {}; - if (body.reason !== undefined) { - const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; - if (!reason) { res.status(400).json({ error: 'reason is required' }); return; } - if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; } - updates.reason = reason; - } - if (body.image_pattern !== undefined) { - const pattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim(); - if (pattern !== null && pattern.length > 300) { - res.status(400).json({ error: 'image_pattern is too long' }); return; - } - updates.image_pattern = pattern; - } - if (body.expires_at !== undefined) { - const expiresAt = body.expires_at == null ? null : Number(body.expires_at); - if (expiresAt !== null && !Number.isFinite(expiresAt)) { - res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return; - } - updates.expires_at = expiresAt; - } - const suppression = DatabaseService.getInstance().updateCveSuppression(id, updates); - if (!suppression) { - res.status(404).json({ error: 'Suppression not found' }); return; - } - FleetSyncService.getInstance().pushResourceAsync('cve_suppressions'); - res.json(suppression); -}); - -app.delete('/api/security/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { - if (!requireAdmin(req, res)) return; - if (!requirePaid(req, res)) return; - if (FleetSyncService.getRole() === 'replica') { - res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); - return; - } - const id = Number(req.params.id); - if (!Number.isFinite(id)) { - res.status(400).json({ error: 'Invalid suppression id' }); return; - } - DatabaseService.getInstance().deleteCveSuppression(id); - FleetSyncService.getInstance().pushResourceAsync('cve_suppressions'); - res.json({ success: true }); -}); - -app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; - const scanId1 = Number(req.query.scanId1); - const scanId2 = Number(req.query.scanId2); - if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) { - res.status(400).json({ error: 'scanId1 and scanId2 are required' }); return; - } - const db = DatabaseService.getInstance(); - const a = db.getVulnerabilityScan(scanId1); - const b = db.getVulnerabilityScan(scanId2); - if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) { - res.status(404).json({ error: 'One or both scans not found' }); return; - } - const COMPARE_ROW_LIMIT = 1000; - const aVulns = db.getVulnerabilityDetails(scanId1, { limit: COMPARE_ROW_LIMIT }).items; - const bVulns = db.getVulnerabilityDetails(scanId2, { limit: COMPARE_ROW_LIMIT }).items; - const truncated = - a.total_vulnerabilities > COMPARE_ROW_LIMIT || b.total_vulnerabilities > COMPARE_ROW_LIMIT; - if (truncated) { - console.warn( - `[Compare] scan(s) exceed ${COMPARE_ROW_LIMIT}-row cap: scanA=${a.id}(${a.total_vulnerabilities}) scanB=${b.id}(${b.total_vulnerabilities})`, - ); - } - const keyOf = (v: { vulnerability_id: string; pkg_name: string }) => - `${v.vulnerability_id}::${v.pkg_name}`; - const aMap = new Map(aVulns.map((v) => [keyOf(v), v])); - const bMap = new Map(bVulns.map((v) => [keyOf(v), v])); - const addedRaw = bVulns.filter((v) => !aMap.has(keyOf(v))); - const removedRaw = aVulns.filter((v) => !bMap.has(keyOf(v))); - const unchangedRaw = aVulns.filter((v) => bMap.has(keyOf(v))); - const suppressions = db.getCveSuppressions(); - const added = applySuppressions(addedRaw, b.image_ref, suppressions); - const removed = applySuppressions(removedRaw, a.image_ref, suppressions); - const unchanged = applySuppressions(unchangedRaw, b.image_ref, suppressions); - if (isDebugEnabled()) { - console.log('[Compare:diag]', { - scanId1, - scanId2, - reqNodeId: req.nodeId, - tier: req.proxyTier ?? LicenseService.getInstance().getTier(), - aVulns: aVulns.length, - bVulns: bVulns.length, - added: added.length, - removed: removed.length, - unchanged: unchanged.length, - suppressions: suppressions.length, - truncated, - }); - } - res.json({ - scanA: { - id: a.id, - scanned_at: a.scanned_at, - image_ref: a.image_ref, - total_vulnerabilities: a.total_vulnerabilities, - }, - scanB: { - id: b.id, - scanned_at: b.scanned_at, - image_ref: b.image_ref, - total_vulnerabilities: b.total_vulnerabilities, - }, - added, - removed, - unchanged, - truncated, - row_limit: COMPARE_ROW_LIMIT, - }); -}); // ========================= // Node Management API diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts new file mode 100644 index 00000000..bf50d930 --- /dev/null +++ b/backend/src/routes/security.ts @@ -0,0 +1,699 @@ +import { Router, type Request, type Response } from 'express'; +import { authMiddleware } from '../middleware/auth'; +import { requireAdmin, requireAdmiral, requirePaid } from '../middleware/tierGates'; +import { trivyInstallLimiter } from '../middleware/rateLimiters'; +import TrivyService, { SbomFormat } from '../services/TrivyService'; +import TrivyInstaller from '../services/TrivyInstaller'; +import { DatabaseService, parsePolicyEvaluation, type VulnerabilityScan } from '../services/DatabaseService'; +import { FleetSyncService } from '../services/FleetSyncService'; +import { LicenseService } from '../services/LicenseService'; +import { validateImageRef } from '../utils/image-ref'; +import { applySuppressions } from '../utils/suppression-filter'; +import { generateSarif } from '../services/SarifExporter'; +import { getErrorMessage } from '../utils/errors'; +import { isDebugEnabled } from '../utils/debug'; + +const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/; + +function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | undefined | null { + if (raw === undefined || raw === null) return undefined; + if (!Array.isArray(raw) || raw.length === 0) return null; + const out = new Set<'vuln' | 'secret'>(); + for (const item of raw) { + if (item !== 'vuln' && item !== 'secret') return null; + out.add(item); + } + return Array.from(out) as readonly ('vuln' | 'secret')[]; +} + +function shapeScanForResponse(scan: VulnerabilityScan): Omit & { + policy_evaluation: ReturnType; +} { + const { policy_evaluation, ...rest } = scan; + return { ...rest, policy_evaluation: parsePolicyEvaluation(policy_evaluation) }; +} + +function fetchAllPages( + q: (opts: { limit?: number; offset?: number }) => { items: T[]; total: number }, +): T[] { + const pageSize = 1000; + const collected: T[] = []; + let offset = 0; + while (true) { + const page = q({ limit: pageSize, offset }); + collected.push(...page.items); + if (collected.length >= page.total || page.items.length === 0) break; + offset += page.items.length; + } + return collected; +} + +export const securityRouter = Router(); + +securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Response) => { + const svc = TrivyService.getInstance(); + const installer = TrivyInstaller.getInstance(); + const settings = DatabaseService.getInstance().getGlobalSettings(); + res.json({ + available: svc.isTrivyAvailable(), + version: svc.getVersion(), + source: svc.getSource(), + autoUpdate: settings.trivy_auto_update === '1', + busy: installer.isBusy(), + }); +}); + +securityRouter.post('/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() === 'host') { + res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' }); + return; + } + if (svc.getSource() === 'managed') { + res.status(409).json({ error: 'Trivy is already installed. Use the update endpoint instead.' }); + return; + } + try { + const { version } = await TrivyInstaller.getInstance().install(); + await svc.detectTrivy(); + res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() }); + } catch (err) { + const msg = getErrorMessage(err, 'Install failed'); + console.error('[Security] Trivy install failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +securityRouter.delete('/trivy-install', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() !== 'managed') { + res.status(409).json({ error: 'No managed Trivy install to remove' }); + return; + } + try { + await TrivyInstaller.getInstance().uninstall(); + await svc.detectTrivy(); + res.json({ available: svc.isTrivyAvailable(), source: svc.getSource() }); + } catch (err) { + const msg = getErrorMessage(err, 'Uninstall failed'); + console.error('[Security] Trivy uninstall failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +securityRouter.get('/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() !== 'managed') { + res.status(409).json({ error: 'Update checks only apply to managed installs' }); + return; + } + try { + const result = await TrivyInstaller.getInstance().checkForUpdate(svc.getVersion(), svc.getSource()); + res.json(result); + } catch (err) { + const msg = getErrorMessage(err, 'Update check failed'); + console.error('[Security] Trivy update check failed:', msg); + res.status(502).json({ error: msg }); + } +}); + +securityRouter.post('/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() !== 'managed') { + res.status(409).json({ error: 'Update only applies to managed installs' }); + return; + } + try { + const { version } = await TrivyInstaller.getInstance().update(); + await svc.detectTrivy(); + res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() }); + } catch (err) { + const msg = getErrorMessage(err, 'Update failed'); + console.error('[Security] Trivy update failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmiral(req, res)) return; + const enabled = req.body?.enabled === true; + try { + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', enabled ? '1' : '0'); + res.json({ autoUpdate: enabled }); + } catch (err) { + const msg = getErrorMessage(err, 'Failed to update setting'); + console.error('[Security] Trivy auto-update toggle failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + const svc = TrivyService.getInstance(); + if (!svc.isTrivyAvailable()) { + res.status(503).json({ error: 'Trivy is not available on this host' }); + return; + } + const rawImageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : ''; + if (!rawImageRef) { + res.status(400).json({ error: 'imageRef is required' }); + return; + } + if (!validateImageRef(rawImageRef)) { + res.status(400).json({ error: 'Invalid imageRef format' }); + return; + } + const imageRef = rawImageRef; + const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null; + const force = req.body?.force === true; + const scanners = parseScannersInput(req.body?.scanners); + if (scanners === null) { + res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' }); + return; + } + if (scanners?.includes('secret') && !requirePaid(req, res)) return; + const nodeId = req.nodeId; + if (svc.isScanning(nodeId, imageRef)) { + res.status(409).json({ error: 'Already scanning this image' }); + return; + } + const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext, scanners); + res.status(202).json({ scanId }); + + svc.finishScan(scanId, imageRef, nodeId, { useCache: !force, scanners }).catch((err) => { + console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message); + }); +}); + +securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const svc = TrivyService.getInstance(); + if (!svc.isTrivyAvailable()) { + res.status(503).json({ error: 'Trivy is not available on this host' }); return; + } + const stackName = typeof req.body?.stackName === 'string' ? req.body.stackName.trim() : ''; + if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); return; + } + try { + const scan = await svc.scanComposeStack(req.nodeId, stackName, 'manual'); + res.status(201).json(scan); + } catch (error) { + const message = (error as Error).message || ''; + if (message === 'Invalid stack path' || message.startsWith('No compose file found')) { + res.status(404).json({ error: message }); return; + } + console.error('[Security] Stack config scan failed:', error); + res.status(500).json({ error: message || 'Failed to scan stack' }); + } +}); + +securityRouter.get('/scans', authMiddleware, (req: Request, res: Response) => { + try { + const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined; + const imageRefLike = + typeof req.query.imageRefLike === 'string' && req.query.imageRefLike.trim() + ? req.query.imageRefLike.trim() + : undefined; + const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined; + const status = + statusParam === 'completed' || statusParam === 'in_progress' || statusParam === 'failed' + ? statusParam + : undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, { + imageRef, + imageRefLike, + status, + limit, + offset, + }); + res.json({ ...result, items: result.items.map(shapeScanForResponse) }); + } catch (error) { + console.error('[Security] Failed to list scans:', error); + res.status(500).json({ error: 'Failed to list scans' }); + } +}); + +securityRouter.get('/scans/:scanId', authMiddleware, (req: Request, res: Response): void => { + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + res.json(shapeScanForResponse(scan)); +}); + +securityRouter.get( + '/scans/:scanId/vulnerabilities', + authMiddleware, + (req: Request, res: Response): void => { + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + const severity = typeof req.query.severity === 'string' + ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') + : undefined; + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); + if (severity && !validSeverities.has(severity)) { + res.status(400).json({ error: 'Invalid severity filter' }); return; + } + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset }); + const suppressions = db.getCveSuppressions(); + const enriched = applySuppressions(result.items, scan.image_ref, suppressions); + res.json({ ...result, items: enriched }); + }, +); + +securityRouter.get( + '/scans/:scanId/secrets', + authMiddleware, + (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + const severity = typeof req.query.severity === 'string' + ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') + : undefined; + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); + if (severity && !validSeverities.has(severity)) { + res.status(400).json({ error: 'Invalid severity filter' }); return; + } + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + res.json(db.getSecretFindings(scanId, { severity, limit, offset })); + }, +); + +securityRouter.get( + '/scans/:scanId/misconfigs', + authMiddleware, + (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + const severity = typeof req.query.severity === 'string' + ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') + : undefined; + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); + if (severity && !validSeverities.has(severity)) { + res.status(400).json({ error: 'Invalid severity filter' }); return; + } + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + res.json(db.getMisconfigFindings(scanId, { severity, limit, offset })); + }, +); + +securityRouter.get('/image-summaries', authMiddleware, (req: Request, res: Response) => { + try { + const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId); + res.json(summaries); + } catch (error) { + console.error('[Security] Failed to fetch image summaries:', error); + res.status(500).json({ error: 'Failed to fetch image summaries' }); + } +}); + +securityRouter.post('/sbom', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const svc = TrivyService.getInstance(); + if (!svc.isTrivyAvailable()) { + res.status(503).json({ error: 'Trivy is not available on this host' }); return; + } + const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : ''; + const formatRaw = typeof req.body?.format === 'string' ? req.body.format : 'spdx-json'; + if (!imageRef) { + res.status(400).json({ error: 'imageRef is required' }); return; + } + if (!validateImageRef(imageRef)) { + res.status(400).json({ error: 'Invalid imageRef format' }); return; + } + if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') { + res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return; + } + try { + const sbom = await svc.generateSBOM(imageRef, formatRaw as SbomFormat); + const safeName = imageRef.replace(/[^a-zA-Z0-9._-]/g, '_'); + const ext = formatRaw === 'spdx-json' ? 'spdx.json' : 'cdx.json'; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${ext}"`); + res.send(sbom); + } catch (error) { + console.error('[Security] SBOM generation failed:', error); + res.status(500).json({ error: (error as Error).message || 'Failed to generate SBOM' }); + } +}); + +securityRouter.get( + '/scans/:scanId/sarif', + authMiddleware, + (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + if (scan.status !== 'completed') { + res.status(409).json({ error: 'Scan not complete' }); return; + } + try { + const details = fetchAllPages((opts) => db.getVulnerabilityDetails(scanId, opts)); + const secrets = fetchAllPages((opts) => db.getSecretFindings(scanId, opts)); + const misconfigs = fetchAllPages((opts) => db.getMisconfigFindings(scanId, opts)); + const suppressed = applySuppressions(details, scan.image_ref, db.getCveSuppressions()); + const sarif = generateSarif(scan, suppressed, secrets, misconfigs); + const safeName = scan.image_ref.replace(/[^a-zA-Z0-9._-]/g, '_') || `scan-${scanId}`; + res.setHeader('Content-Type', 'application/sarif+json'); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}.sarif.json"`); + res.send(JSON.stringify(sarif)); + } catch (error) { + console.error('[Security] SARIF export failed:', error); + res.status(500).json({ error: (error as Error).message || 'Failed to generate SARIF' }); + } + }, +); + +securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + res.json(DatabaseService.getInstance().getScanPolicies()); +}); + +securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ error: 'Security policies are managed from the control node.' }); + return; + } + const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {}; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'Policy name is required' }); return; + } + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); + if (!validSeverities.has(max_severity)) { + res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return; + } + try { + const resolvedNodeId = node_id != null ? Number(node_id) : null; + const policy = DatabaseService.getInstance().createScanPolicy({ + name: name.trim(), + node_id: resolvedNodeId, + node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId), + stack_pattern: stack_pattern ? String(stack_pattern) : null, + max_severity, + block_on_deploy: block_on_deploy ? 1 : 0, + enabled: enabled === false ? 0 : 1, + replicated_from_control: 0, + }); + FleetSyncService.getInstance().pushResourceAsync('scan_policies'); + res.status(201).json(policy); + } catch (error) { + console.error('[Security] Failed to create policy:', error); + res.status(500).json({ error: 'Failed to create policy' }); + } +}); + +securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ error: 'Security policies are managed from the control node.' }); + return; + } + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid policy id' }); return; + } + const body = req.body ?? {}; + const updates: Record = {}; + if (body.name !== undefined) updates.name = String(body.name).trim(); + if (body.node_id !== undefined) { + const resolvedNodeId = body.node_id != null ? Number(body.node_id) : null; + updates.node_id = resolvedNodeId; + updates.node_identity = FleetSyncService.resolveIdentityForNodeId(resolvedNodeId); + } + if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null; + if (body.max_severity !== undefined) { + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); + if (!validSeverities.has(body.max_severity)) { + res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return; + } + updates.max_severity = body.max_severity; + } + if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0; + if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0; + const policy = DatabaseService.getInstance().updateScanPolicy(id, updates); + if (!policy) { + res.status(404).json({ error: 'Policy not found' }); return; + } + FleetSyncService.getInstance().pushResourceAsync('scan_policies'); + res.json(policy); +}); + +securityRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ error: 'Security policies are managed from the control node.' }); + return; + } + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid policy id' }); return; + } + DatabaseService.getInstance().deleteScanPolicy(id); + FleetSyncService.getInstance().pushResourceAsync('scan_policies'); + res.json({ success: true }); +}); + +securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + const now = Date.now(); + const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({ + ...s, + active: s.expires_at === null || s.expires_at > now, + })); + res.json(rows); +}); + +securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); + return; + } + const body = req.body ?? {}; + const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; + if (!CVE_ID_RE.test(cveId)) { + res.status(400).json({ error: 'cve_id must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx' }); + return; + } + const pkgName = body.pkg_name == null || body.pkg_name === '' ? null : String(body.pkg_name).trim(); + if (pkgName !== null && pkgName.length > 200) { + res.status(400).json({ error: 'pkg_name is too long' }); return; + } + const imagePattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim(); + if (imagePattern !== null && imagePattern.length > 300) { + res.status(400).json({ error: 'image_pattern is too long' }); return; + } + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + if (!reason) { + res.status(400).json({ error: 'reason is required' }); return; + } + if (reason.length > 2000) { + res.status(400).json({ error: 'reason is too long' }); return; + } + const expiresAt = body.expires_at == null ? null : Number(body.expires_at); + if (expiresAt !== null && !Number.isFinite(expiresAt)) { + res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return; + } + try { + const suppression = DatabaseService.getInstance().createCveSuppression({ + cve_id: cveId, + pkg_name: pkgName, + image_pattern: imagePattern, + reason, + created_by: req.user?.username || 'unknown', + created_at: Date.now(), + expires_at: expiresAt, + replicated_from_control: 0, + }); + FleetSyncService.getInstance().pushResourceAsync('cve_suppressions'); + res.status(201).json(suppression); + } catch (error) { + const message = (error as Error).message || ''; + if (message.includes('UNIQUE')) { + res.status(409).json({ error: 'A suppression already exists for this CVE, package, and image pattern.' }); + return; + } + console.error('[Security] Failed to create suppression:', error); + res.status(500).json({ error: 'Failed to create suppression' }); + } +}); + +securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); + return; + } + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid suppression id' }); return; + } + const body = req.body ?? {}; + const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null }> = {}; + if (body.reason !== undefined) { + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + if (!reason) { res.status(400).json({ error: 'reason is required' }); return; } + if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; } + updates.reason = reason; + } + if (body.image_pattern !== undefined) { + const pattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim(); + if (pattern !== null && pattern.length > 300) { + res.status(400).json({ error: 'image_pattern is too long' }); return; + } + updates.image_pattern = pattern; + } + if (body.expires_at !== undefined) { + const expiresAt = body.expires_at == null ? null : Number(body.expires_at); + if (expiresAt !== null && !Number.isFinite(expiresAt)) { + res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return; + } + updates.expires_at = expiresAt; + } + const suppression = DatabaseService.getInstance().updateCveSuppression(id, updates); + if (!suppression) { + res.status(404).json({ error: 'Suppression not found' }); return; + } + FleetSyncService.getInstance().pushResourceAsync('cve_suppressions'); + res.json(suppression); +}); + +securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ error: 'CVE suppressions are managed from the control node.' }); + return; + } + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid suppression id' }); return; + } + DatabaseService.getInstance().deleteCveSuppression(id); + FleetSyncService.getInstance().pushResourceAsync('cve_suppressions'); + res.json({ success: true }); +}); + +securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + const scanId1 = Number(req.query.scanId1); + const scanId2 = Number(req.query.scanId2); + if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) { + res.status(400).json({ error: 'scanId1 and scanId2 are required' }); return; + } + const db = DatabaseService.getInstance(); + const a = db.getVulnerabilityScan(scanId1); + const b = db.getVulnerabilityScan(scanId2); + if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) { + res.status(404).json({ error: 'One or both scans not found' }); return; + } + const COMPARE_ROW_LIMIT = 1000; + const aVulns = db.getVulnerabilityDetails(scanId1, { limit: COMPARE_ROW_LIMIT }).items; + const bVulns = db.getVulnerabilityDetails(scanId2, { limit: COMPARE_ROW_LIMIT }).items; + const truncated = + a.total_vulnerabilities > COMPARE_ROW_LIMIT || b.total_vulnerabilities > COMPARE_ROW_LIMIT; + if (truncated) { + console.warn( + `[Compare] scan(s) exceed ${COMPARE_ROW_LIMIT}-row cap: scanA=${a.id}(${a.total_vulnerabilities}) scanB=${b.id}(${b.total_vulnerabilities})`, + ); + } + const keyOf = (v: { vulnerability_id: string; pkg_name: string }) => + `${v.vulnerability_id}::${v.pkg_name}`; + const aMap = new Map(aVulns.map((v) => [keyOf(v), v])); + const bMap = new Map(bVulns.map((v) => [keyOf(v), v])); + const addedRaw = bVulns.filter((v) => !aMap.has(keyOf(v))); + const removedRaw = aVulns.filter((v) => !bMap.has(keyOf(v))); + const unchangedRaw = aVulns.filter((v) => bMap.has(keyOf(v))); + const suppressions = db.getCveSuppressions(); + const added = applySuppressions(addedRaw, b.image_ref, suppressions); + const removed = applySuppressions(removedRaw, a.image_ref, suppressions); + const unchanged = applySuppressions(unchangedRaw, b.image_ref, suppressions); + if (isDebugEnabled()) { + console.log('[Compare:diag]', { + scanId1, + scanId2, + reqNodeId: req.nodeId, + tier: req.proxyTier ?? LicenseService.getInstance().getTier(), + aVulns: aVulns.length, + bVulns: bVulns.length, + added: added.length, + removed: removed.length, + unchanged: unchanged.length, + suppressions: suppressions.length, + truncated, + }); + } + res.json({ + scanA: { + id: a.id, + scanned_at: a.scanned_at, + image_ref: a.image_ref, + total_vulnerabilities: a.total_vulnerabilities, + }, + scanB: { + id: b.id, + scanned_at: b.scanned_at, + image_ref: b.image_ref, + total_vulnerabilities: b.total_vulnerabilities, + }, + added, + removed, + unchanged, + truncated, + row_limit: COMPARE_ROW_LIMIT, + }); +});