admin / Synapse-Sonar
publicAttack Surface Simulation
Synapse-Sonar / synapse-sonar / lib / blastRadius.ts
2709 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | import { prisma } from "@/lib/prisma"; export interface BlastRadiusResult { origin: string; /** Asset IDs reachable within `maxHops`, with the hop distance at which they were first reached. */ reachable: { assetId: string; hop: number }[]; /** Edge IDs that form the lateral-movement path (for highlighting on the canvas). */ pathEdgeIds: string[]; } /** * Blast Radius Simulation. * * Given a compromised asset, return every downstream asset reachable within * `maxHops` (default 2) by following directed Connection edges — strictly * scoped to the asset's tenant. * * Implemented as a Postgres recursive CTE so the traversal happens in the DB. * Tenant ownership is verified first, and every recursive step re-asserts the * tenantId so a malicious/buggy caller can never walk into another tenant's graph. */ export async function computeBlastRadius( tenantId: string, originAssetId: string, maxHops = 2 ): Promise<BlastRadiusResult> { // 1. Verify tenant ownership of the origin node. const origin = await prisma.asset.findFirst({ where: { id: originAssetId, tenantId }, select: { id: true }, }); if (!origin) { throw new Error("Asset not found for this tenant"); } // 2. Recursive CTE: BFS over directed edges, bounded by maxHops, tenant-locked. // Offline assets are excluded: a node marked offline is neither reached nor // traversed through (it can't carry lateral movement). DISTINCT ON keeps the // shortest hop for each reached asset. const rows = await prisma.$queryRaw< { asset_id: string; hop: number; edge_id: string | null }[] >` WITH RECURSIVE reachable AS ( -- hop 0: the origin itself SELECT ${originAssetId}::text AS asset_id, 0 AS hop, NULL::text AS edge_id UNION ALL -- expand one directed hop at a time, only into ONLINE assets SELECT c."targetAssetId" AS asset_id, r.hop + 1 AS hop, c."id" AS edge_id FROM reachable r JOIN "connections" c ON c."sourceAssetId" = r.asset_id AND c."tenantId" = ${tenantId} JOIN "assets" a ON a."id" = c."targetAssetId" AND a."manualOffline" = false WHERE r.hop < ${maxHops} ) SELECT DISTINCT ON (asset_id) asset_id, hop, edge_id FROM reachable ORDER BY asset_id, hop ASC; `; const reachable = rows .filter((r) => r.asset_id !== originAssetId) .map((r) => ({ assetId: r.asset_id, hop: r.hop })); const pathEdgeIds = rows .map((r) => r.edge_id) .filter((id): id is string => Boolean(id)); return { origin: originAssetId, reachable, pathEdgeIds }; } |