diff --git a/backend/src/__tests__/suppression-routes.test.ts b/backend/src/__tests__/suppression-routes.test.ts index a9e3c51e..6bd504c3 100644 --- a/backend/src/__tests__/suppression-routes.test.ts +++ b/backend/src/__tests__/suppression-routes.test.ts @@ -140,7 +140,7 @@ describe('POST /api/security/suppressions', () => { .set('Authorization', adminAuthHeader) .send(validBody); expect(res.status).toBe(403); - expect(res.body.error).toMatch(/control node/i); + expect(res.body.code).toBe('REPLICA_READ_ONLY'); }); it('rejects malformed CVE identifiers', async () => { diff --git a/backend/src/middleware/fleetSyncGuards.ts b/backend/src/middleware/fleetSyncGuards.ts new file mode 100644 index 00000000..0d6e2d11 --- /dev/null +++ b/backend/src/middleware/fleetSyncGuards.ts @@ -0,0 +1,23 @@ +import type { Response } from 'express'; +import { FleetSyncService } from '../services/FleetSyncService'; + +/** + * Reject mutation attempts on a replica Sencho. Returns true (and writes a + * 403 response) when this instance is acting as a replica; the caller + * should `return` immediately. Returns false on a control instance so the + * route handler can continue. + * + * Replicas mirror security configuration (scan policies, CVE suppressions) + * from the control node and reject local writes to keep the fleet + * authoritative source single. + */ +export function blockIfReplica(res: Response, resource: string): boolean { + if (FleetSyncService.getRole() === 'replica') { + res.status(403).json({ + error: `Cannot modify ${resource} on a replica instance. Connect to the primary.`, + code: 'REPLICA_READ_ONLY', + }); + return true; + } + return false; +} diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index e9c6bc94..5c9a095c 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -12,6 +12,7 @@ import { applySuppressions } from '../utils/suppression-filter'; import { generateSarif } from '../services/SarifExporter'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; +import { blockIfReplica } from '../middleware/fleetSyncGuards'; import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity'; const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/; @@ -418,10 +419,7 @@ securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): v 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; - } + if (blockIfReplica(res, 'security policies')) 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; @@ -452,10 +450,7 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): 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; - } + if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid policy id' }); return; @@ -488,10 +483,7 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response 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; - } + if (blockIfReplica(res, 'security policies')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid policy id' }); return; @@ -514,10 +506,7 @@ securityRouter.get('/suppressions', authMiddleware, (req: Request, res: Response 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; - } + if (blockIfReplica(res, 'CVE suppressions')) return; const body = req.body ?? {}; const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : ''; if (!CVE_ID_RE.test(cveId)) { @@ -570,10 +559,7 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons 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; - } + if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid suppression id' }); return; @@ -611,10 +597,7 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp 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; - } + if (blockIfReplica(res, 'CVE suppressions')) return; const id = Number(req.params.id); if (!Number.isFinite(id)) { res.status(400).json({ error: 'Invalid suppression id' }); return;