Catalyst / admin/Synapse-Sonar 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Sonar

public

Attack Surface Simulation

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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 };
}