From a284732a95675234c316e60a819ee0a02cbd9bc9 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 7 May 2026 13:48:11 -0400 Subject: [PATCH] feat(fleet-sync): hide other replicas' identity-scoped policies on a replica (#973) GET /api/security/policies on a replica now returns only the policies that apply to THIS replica. Replicated rows with a node_identity targeting a sibling replica are filtered out so an operator cannot enumerate the names and rules of policies meant for another node in the fleet. Defense in depth: the security panel is admin-only, but a backend filter is bypass-proof and matches Sencho's privacy posture. Internal evaluators (getMatchingPolicy, evaluateScanAgainstPolicies) keep using the unfiltered list because they already enforce identity matching at evaluation time. CVE suppressions are fleet-wide on every replica (no node_identity column) so no analogous filter is needed. Public surface: - DatabaseService.getScanPoliciesForUi(role, selfIdentity): the filtered variant, called from securityRouter.get('/policies'). Tests: - 3 new vitest cases: control sees full set; replica hides other-replica scoped rows; replica always includes locally created rows. - Full backend suite: 1795 pass / 5 skipped. --- .../database-policies-for-ui.test.ts | 110 ++++++++++++++++++ backend/src/routes/security.ts | 7 +- backend/src/services/DatabaseService.ts | 27 +++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 backend/src/__tests__/database-policies-for-ui.test.ts diff --git a/backend/src/__tests__/database-policies-for-ui.test.ts b/backend/src/__tests__/database-policies-for-ui.test.ts new file mode 100644 index 00000000..eeaf327b --- /dev/null +++ b/backend/src/__tests__/database-policies-for-ui.test.ts @@ -0,0 +1,110 @@ +/** + * Pins the replica-side filtering of `getScanPoliciesForUi`. + * + * On a replica the security-settings panel must only render policies that + * apply to that replica. Replicated rows with a node_identity targeting a + * sibling replica are filtered out so an operator cannot enumerate other + * replicas' identity-scoped rules. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + const db = DatabaseService.getInstance(); + // Wipe all replicated and local rows between tests so each scenario starts clean. + db.clearReplicatedRows(); + for (const p of db.getScanPolicies()) { + db.deleteScanPolicy(p.id); + } +}); + +function seedFleetWideReplicated(name: string): void { + DatabaseService.getInstance().replaceReplicatedScanPolicies([ + { + id: 0, + name, + node_id: null, + node_identity: '', + stack_pattern: '*', + max_severity: 'HIGH', + block_on_deploy: 0, + enabled: 1, + replicated_from_control: 1, + created_at: Date.now(), + updated_at: Date.now(), + }, + ]); +} + +function seedReplicaScopedReplicated(name: string, nodeIdentity: string): void { + const db = DatabaseService.getInstance(); + // Append-style: read existing replicated rows + the new scoped one. + const existing = db.getScanPolicies().filter((p) => p.replicated_from_control === 1); + db.replaceReplicatedScanPolicies([ + ...existing, + { + id: 0, + name, + node_id: null, + node_identity: nodeIdentity, + stack_pattern: '*', + max_severity: 'CRITICAL', + block_on_deploy: 0, + enabled: 1, + replicated_from_control: 1, + created_at: Date.now(), + updated_at: Date.now(), + }, + ]); +} + +describe('getScanPoliciesForUi', () => { + it('returns the full set on a control instance', () => { + const db = DatabaseService.getInstance(); + seedFleetWideReplicated('fleet-wide'); + seedReplicaScopedReplicated('targets-other', 'https://other.example'); + const result = db.getScanPoliciesForUi('control', 'local'); + expect(result.map((p) => p.name).sort()).toEqual(['fleet-wide', 'targets-other']); + }); + + it('hides identity-scoped replicated rows that target a different replica', () => { + const db = DatabaseService.getInstance(); + seedFleetWideReplicated('fleet-wide'); + seedReplicaScopedReplicated('targets-other', 'https://other.example'); + seedReplicaScopedReplicated('targets-self', 'https://me.example'); + const result = db.getScanPoliciesForUi('replica', 'https://me.example'); + const names = result.map((p) => p.name).sort(); + expect(names).toEqual(['fleet-wide', 'targets-self']); + }); + + it('always includes locally created rows on a replica', () => { + const db = DatabaseService.getInstance(); + seedReplicaScopedReplicated('targets-other', 'https://other.example'); + db.createScanPolicy({ + name: 'local-on-replica', + node_id: null, + node_identity: '', + stack_pattern: null, + max_severity: 'CRITICAL', + block_on_deploy: 0, + enabled: 1, + replicated_from_control: 0, + }); + const result = db.getScanPoliciesForUi('replica', 'https://me.example'); + const names = result.map((p) => p.name).sort(); + expect(names).toContain('local-on-replica'); + expect(names).not.toContain('targets-other'); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 94468b44..a98ae812 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -410,7 +410,12 @@ securityRouter.get( securityRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; - res.json(DatabaseService.getInstance().getScanPolicies()); + // Replicas see only policies that apply to themselves: local-only rows plus + // fleet-wide and self-identity-matched replicated rows. Identity-scoped + // rows targeting other replicas are filtered out at the SQL boundary. + const policies = DatabaseService.getInstance() + .getScanPoliciesForUi(FleetSyncService.getRole(), FleetSyncService.getSelfIdentity()); + res.json(policies); }); securityRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 77eaeed8..3410c2a0 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -3526,6 +3526,33 @@ export class DatabaseService { .all() as ScanPolicy[]; } + /** + * Variant of `getScanPolicies` for the security-settings UI. + * + * On a control instance: returns the full set, identical to + * `getScanPolicies`. + * + * On a replica: returns only the policies that apply to THIS replica. + * Replicated rows scoped to a different replica's identity (the + * `node_identity` of a sibling node in the fleet) are filtered out so + * an operator on Replica A cannot enumerate the names of identity-scoped + * policies meant for Replica B. Internal evaluators + * (`getMatchingPolicy`, `evaluateScanAgainstPolicies`) keep using the + * unfiltered list because they already enforce identity matching at + * evaluation time. + */ + public getScanPoliciesForUi(role: 'control' | 'replica', selfIdentity: string): ScanPolicy[] { + const all = this.getScanPolicies(); + if (role === 'control') return all; + return all.filter((p) => { + if (p.replicated_from_control === 0) return true; + // Fleet-wide replicated rows have an empty node_identity and + // apply on every replica. + if (!p.node_identity) return true; + return p.node_identity === selfIdentity; + }); + } + public getScanPolicy(id: number): ScanPolicy | null { return ( (this.db