From 6fa4565ebd1acbf263074b15b031d9da26b6138c Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:18:18 +0100 Subject: [PATCH] fix(attack-paths): connect virtual nodes from principal instead of effective_principal When a principal can assume a role (effective_principal), the virtual relationship was being created from effective_principal but the graph showed the original principal, causing disconnected nodes. Now the virtual relationship is created from the original principal, keeping the graph fully connected while still detecting escalation paths that require role assumption. --- .../api/attack_paths/query_definitions.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/api/src/backend/api/attack_paths/query_definitions.py b/api/src/backend/api/attack_paths/query_definitions.py index eae717ee95..be034ad92a 100644 --- a/api/src/backend/api/attack_paths/query_definitions.py +++ b/api/src/backend/api/attack_paths/query_definitions.py @@ -782,10 +782,10 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { ) // Deduplicate per (principal, target_role) pair - WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role + WITH DISTINCT escalation_outcome, aws, principal, target_role // Group by principal, collect target_roles - WITH escalation_outcome, aws, principal, effective_principal, + WITH escalation_outcome, aws, principal, collect(DISTINCT target_role) AS target_roles, count(DISTINCT target_role) AS target_count @@ -793,12 +793,13 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { CALL apoc.create.vNode(['ECSTask'], { name: 'New Task Definition', description: toString(target_count) + ' admin role(s) can be passed', - id: effective_principal.arn, + id: principal.arn, target_role_count: target_count }) YIELD node AS ecs_task - CALL apoc.create.vRelationship(effective_principal, 'CREATES_TASK', { + // Connect from principal (not effective_principal) to keep graph connected + CALL apoc.create.vRelationship(principal, 'CREATES_TASK', { permissions: ['iam:PassRole', 'ecs:RegisterTaskDefinition', 'ecs:CreateService'], technique: 'new-passrole' }, ecs_task) @@ -932,10 +933,10 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { ) // Deduplicate per (principal, target_role) pair - WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role + WITH DISTINCT escalation_outcome, aws, principal, target_role // Group by principal, collect target_roles - WITH escalation_outcome, aws, principal, effective_principal, + WITH escalation_outcome, aws, principal, collect(DISTINCT target_role) AS target_roles, count(DISTINCT target_role) AS target_count @@ -943,12 +944,13 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { CALL apoc.create.vNode(['GlueDevEndpoint'], { name: 'New Dev Endpoint', description: toString(target_count) + ' admin role(s) can be passed', - id: effective_principal.arn, + id: principal.arn, target_role_count: target_count }) YIELD node AS glue_endpoint - CALL apoc.create.vRelationship(effective_principal, 'CREATES_ENDPOINT', { + // Connect from principal (not effective_principal) to keep graph connected + CALL apoc.create.vRelationship(principal, 'CREATES_ENDPOINT', { permissions: ['iam:PassRole', 'glue:CreateDevEndpoint'], technique: 'new-passrole' }, glue_endpoint) @@ -1025,10 +1027,10 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { ) // Deduplicate per (principal, target_role) pair - WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role + WITH DISTINCT escalation_outcome, aws, principal, target_role // Group by principal, collect target_roles - WITH escalation_outcome, aws, principal, effective_principal, + WITH escalation_outcome, aws, principal, collect(DISTINCT target_role) AS target_roles, count(DISTINCT target_role) AS target_count @@ -1036,12 +1038,13 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { CALL apoc.create.vNode(['BedrockCodeInterpreter'], { name: 'New Code Interpreter', description: toString(target_count) + ' admin role(s) can be passed', - id: effective_principal.arn, + id: principal.arn, target_role_count: target_count }) YIELD node AS bedrock_agent - CALL apoc.create.vRelationship(effective_principal, 'CREATES_INTERPRETER', { + // Connect from principal (not effective_principal) to keep graph connected + CALL apoc.create.vRelationship(principal, 'CREATES_INTERPRETER', { permissions: ['iam:PassRole', 'bedrock-agentcore:CreateCodeInterpreter', 'bedrock-agentcore:StartSession', 'bedrock-agentcore:Invoke'], technique: 'new-passrole' }, bedrock_agent) @@ -1058,7 +1061,7 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { }, escalation_outcome) YIELD rel AS grants_rel - // Re-match path for visualization (only principal path, target_role is connected via virtual rels) + // Re-match path for visualization MATCH path_principal = (aws)--(principal) RETURN path_principal, @@ -1110,10 +1113,10 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { ) // Deduplicate per (principal, target_role) pair - WITH DISTINCT escalation_outcome, aws, principal, effective_principal, target_role + WITH DISTINCT escalation_outcome, aws, principal, target_role // Group by principal, collect target_roles - WITH escalation_outcome, aws, principal, effective_principal, + WITH escalation_outcome, aws, principal, collect(DISTINCT target_role) AS target_roles, count(DISTINCT target_role) AS target_count @@ -1121,12 +1124,13 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = { CALL apoc.create.vNode(['CloudFormationStack'], { name: 'New Stack', description: toString(target_count) + ' admin role(s) can be passed', - id: effective_principal.arn, + id: principal.arn, target_role_count: target_count }) YIELD node AS cfn_stack - CALL apoc.create.vRelationship(effective_principal, 'CREATES_STACK', { + // Connect from principal (not effective_principal) to keep graph connected + CALL apoc.create.vRelationship(principal, 'CREATES_STACK', { permissions: ['iam:PassRole', 'cloudformation:CreateStack'], technique: 'new-passrole' }, cfn_stack)