Compare commits

...

22 Commits

Author SHA1 Message Date
Andoni A.
b050c917c6 perf(attack-paths): optimize getPathEdges with O(1) adjacency maps
Pre-build parent and children maps at the start of traversal for O(1)
lookups instead of O(n) array searches per traversal step. This improves
performance for large attack path graphs.
2026-01-14 17:12:13 +01:00
Andoni A.
67933d7d2d Merge branch 'attack-paths-demo' into attack-paths-demo-extras 2026-01-14 17:06:33 +01:00
Andoni A.
6fa4565ebd 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.
2026-01-14 09:18:18 +01:00
Andoni A.
e426c29207 fix(attack-paths): remove duplicate path_target causing disconnected nodes
Removed the extra path_target re-match that was causing role nodes to appear
disconnected in the visualization. The target_role is now only connected via
virtual relationships (PASSES_ROLE → target_role → GRANTS_ACCESS), which
provides a cleaner and more accurate attack path visualization.
2026-01-14 09:11:27 +01:00
Andoni A.
1d8d4f9325 refactor(attack-paths): show target roles in PassRole escalation paths
Updated PassRole queries to display which specific role(s) can be passed
in the visualization, instead of just showing a count. The path now shows:

Principal → New Resource → Target Role → Privilege Escalation

This allows users to see exactly which admin roles a principal can pass
to escalate privileges, which is crucial for security analysis.

Queries updated: Lambda, ECS, Glue, Bedrock, CloudFormation
2026-01-14 09:06:11 +01:00
Andoni A.
cad44a3510 fix(attack-paths): fix duplicate virtual nodes in priv escalation queries
Virtual nodes were being created for each result row, causing duplicates
in the graph visualization. Fixed by using aggregation pattern:
1. Deduplicate principals FIRST (before matching target roles)
2. Collect target roles per principal
3. Create ONE virtual node per principal with role count

Queries fixed:
- aws-iam-privesc-passrole-lambda
- aws-glue-privesc-passrole-dev-endpoint
- aws-bedrock-privesc-passrole-code-interpreter
- aws-cloudformation-privesc-passrole-create-stack

The virtual node description now shows "N admin role(s) can be passed"
instead of creating N separate nodes.
2026-01-14 08:58:26 +01:00
Andoni A.
ee73e043f9 refactor(attack-paths): apply query improvements to remaining priv escalation queries
Apply the same patterns from PR #9770 to the other privilege escalation
queries that were missing the improvements:

- aws-iam-privesc-create-policy-version
- aws-iam-privesc-attach-role-policy-assume-role
- aws-iam-privesc-passrole-lambda
- aws-iam-privesc-role-chain

Changes applied:
- Add DISTINCT deduplication before creating virtual relationships
- Add re-match paths at the end for proper visualization
- Remove redundant path variables from RETURN statements
- Create unique virtual node IDs per principal->target pair
2026-01-13 16:39:32 +01:00
Andoni A.
815797bc2b fix(attack-paths): hide findings completely in full view
- Change opacity-based hiding to display:none for finding nodes
- Use visibility:hidden for finding edges in full view
- Add isFilteredView to useEffect dependency array
- In filtered view, all nodes/edges remain visible as expected
2026-01-12 17:13:52 +01:00
Andoni A.
9cd249c561 fix(attack-paths): show findings at full opacity in filtered view
When in filtered view, findings are part of the selected path and
should be fully visible, not hidden with reduced opacity.

- Add isFilteredView prop to AttackPathGraph component
- Skip hiding findings when isFilteredView is true
2026-01-12 16:59:47 +01:00
Andoni A.
00fe96a9f7 refactor(attack-paths): redraw graph with filtered data for optimal layout
Reverts the visibility-based approach to use data-changing approach
which redraws the graph with only the selected path nodes, optimizing
the layout for the filtered view.

- Store fullData separately when entering filtered view
- Compute filtered subgraph with only visible nodes and edges
- Graph redraws with new data, auto-fitting to show selected path
- Restore fullData when exiting filtered view
2026-01-12 16:56:35 +01:00
Andoni A.
7c45ee1dbb refactor(attack-paths): use visibility-based filtering with D3 transitions
Replace data-replacement approach with visibleNodeIds Set to fix
animation issues caused by Next.js server component re-renders.

- Changed useGraphState to compute visibleNodeIds without modifying data
- Graph component now animates opacity changes via D3 transitions
- Keeps DOM structure stable while providing smooth visual transitions
- Findings now properly appear when filtering by a resource node
2026-01-12 16:50:25 +01:00
Andoni A.
d19a23f829 feat(attack-paths): highlight selected node and show findings in filtered view
- Add orange glow filter and pulsing animation for selected/filtered nodes
- Pass isFilteredView prop to graph component to show findings when filtering
- Update node styling to show thicker border (4px) and orange glow on selection
- Ensure findings are visible in filtered view instead of being hidden by default
2026-01-12 16:41:09 +01:00
Andoni A.
b071fffe57 feat(attack-paths): add filtered view when clicking on graph nodes
When clicking a node in the attack path graph:
- Filters the graph to show only upstream (ancestors) and downstream (descendants) paths
- Includes findings directly connected to the selected node
- Shows a "Back to Full View" button to restore the complete graph
- Displays an indicator showing which node is being filtered

Uses atomic Zustand state updates to ensure proper re-rendering of the D3 graph.
2026-01-12 16:33:30 +01:00
Andoni A.
422c55404b refactor(attack-paths): simplify legend by consolidating resource types
Merge all individual resource type items (AWS Account, EC2 Instance,
S3 Bucket, etc.) into a single "Resource" entry to reduce visual
clutter in the graph legend.
2026-01-12 16:26:20 +01:00
Andoni A.
6c307385b0 fix(attack-paths): preserve aws variable in ECS query WITH clause
Add aws to intermediate WITH clause to fix 'Variable aws not defined'
error when deduplicating principals before matching target roles.
2026-01-12 14:47:12 +01:00
Andoni A.
13964ccb1c fix(attack-paths): merge ECS target roles into single virtual node per principal
Instead of creating one virtual ECS task node per target role, merge all
target roles into a single node per principal. The node description shows
how many admin roles can be passed (e.g., '3 admin role(s) can be passed').

This reduces visual clutter when a principal can pass multiple admin roles.
2026-01-12 13:30:53 +01:00
Andoni A.
64ed526e31 refactor(attack-paths): rename virtual nodes from Malicious to New
The virtual nodes represent potential resources that could be created
for privilege escalation, not actual malicious resources. Renamed for
clarity:
- Malicious Task Definition -> New Task Definition
- Malicious Dev Endpoint -> New Dev Endpoint
- Malicious Code Interpreter -> New Code Interpreter
- Malicious Stack -> New Stack
2026-01-09 15:05:12 +01:00
Andoni A.
2388a053ee fix(attack-paths): highlight single path upstream instead of all paths
Changed upstream traversal to follow only one parent at each level
instead of all parents. This prevents the entire graph from lighting
up when selecting a node that has multiple ancestors with many children.

- Upstream: now uses find() to get first parent only
- Downstream: unchanged, still highlights all descendants
2026-01-08 18:18:44 +01:00
Andoni A.
7bb5354275 feat(attack-paths): improve graph visualization and interactions
- Change edge color from orange to white by default
- Highlight entire path in orange on node hover/selection
- Add Ctrl + scroll to zoom functionality with increased speed
- Update node borders to orange on hover/selection
- Add zoom hint to legend
- Remove hover effect from info button
2026-01-08 17:57:24 +01:00
Andoni A.
03cae9895b wip 2026-01-08 15:43:59 +01:00
Andoni A.
e398b654d4 merge all privesc nodes into one, change it to look like a finding 2026-01-07 16:45:40 +01:00
Andoni A.
d9e978af29 initial version 2026-01-07 10:50:29 +01:00
10 changed files with 994 additions and 93 deletions

View File

@@ -336,6 +336,78 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
),
],
),
# =====================================================================
# Privilege Escalation Queries (based on pathfinding.cloud research)
# Reference: https://github.com/DataDog/pathfinding.cloud
# =====================================================================
AttackPathsQueryDefinition(
id="aws-iam-privesc-create-policy-version",
name="Privilege Escalation: iam:CreatePolicyVersion",
description="Detect principals with iam:CreatePolicyVersion permission who can modify policies attached to themselves or others, enabling privilege escalation by creating a new policy version with elevated permissions. This is a self-escalation path (pathfinding.cloud: iam-001).",
provider="aws",
cypher="""
// Create a single shared virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-createpolicyversion',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals with iam:CreatePolicyVersion permission
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Find policies attached to the principal
MATCH path_attached = (principal)--(attached_policy:AWSPolicy)
WHERE attached_policy.type = 'Customer Managed'
// Find policy statements that grant iam:CreatePolicyVersion
MATCH path_perms = (principal)--(perms_policy:AWSPolicy)--(stmt:AWSPolicyStatement)
WHERE stmt.effect = 'Allow'
AND (
any(action IN stmt.action WHERE
toLower(action) = 'iam:createpolicyversion'
OR toLower(action) = 'iam:*'
OR action = '*'
)
)
// Check resource constraints - can they modify the attached policy?
AND (
any(resource IN stmt.resource WHERE
resource = '*'
OR attached_policy.arn CONTAINS resource
OR resource CONTAINS attached_policy.name
)
)
// Deduplicate before creating virtual relationships
WITH DISTINCT escalation_outcome, aws, principal, attached_policy
CALL apoc.create.vRelationship(principal, 'CAN_ESCALATE_TO', {
via: 'iam:CreatePolicyVersion',
target_policy: attached_policy.arn,
reference: 'https://pathfinding.cloud/paths/iam-001'
}, escalation_outcome)
YIELD rel AS escalation_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
MATCH path_attached = (principal)--(attached_policy)
UNWIND nodes(path_principal) + nodes(path_attached) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path_principal, path_attached,
escalation_outcome, escalation_rel,
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-privesc-attach-role-policy-assume-role",
name="Privilege Escalation: iam:AttachRolePolicy + sts:AssumeRole",
@@ -421,6 +493,488 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-privesc-passrole-ec2",
name="Privilege Escalation: iam:PassRole + ec2:RunInstances",
description="Detect principals who can launch EC2 instances with privileged IAM roles attached. This allows gaining the permissions of the passed role by accessing the EC2 instance metadata service. This is a new-passrole escalation path (pathfinding.cloud: ec2-001).",
provider="aws",
cypher="""
// Create a single shared virtual EC2 instance node
CALL apoc.create.vNode(['EC2Instance'], {
id: 'potential-ec2-passrole',
name: 'New EC2 Instance',
description: 'Attacker-controlled EC2 with privileged role'
})
YIELD node AS ec2_node
// Create a single shared virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-passrole-ec2',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH ec2_node, escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Find statements granting iam:PassRole
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
WHERE stmt_passrole.effect = 'Allow'
AND any(action IN stmt_passrole.action WHERE
toLower(action) = 'iam:passrole'
OR toLower(action) = 'iam:*'
OR action = '*'
)
// Find statements granting ec2:RunInstances
MATCH path_ec2 = (principal)--(ec2_policy:AWSPolicy)--(stmt_ec2:AWSPolicyStatement)
WHERE stmt_ec2.effect = 'Allow'
AND any(action IN stmt_ec2.action WHERE
toLower(action) = 'ec2:runinstances'
OR toLower(action) = 'ec2:*'
OR action = '*'
)
// Find roles that trust EC2 service (can be passed to EC2)
MATCH path_target = (aws)--(target_role:AWSRole)
WHERE target_role.arn CONTAINS $provider_uid
// Check if principal can pass this role
AND any(resource IN stmt_passrole.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
OR resource CONTAINS target_role.name
)
// Check if target role has elevated permissions (optional, for severity assessment)
OPTIONAL MATCH (target_role)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
WHERE role_stmt.effect = 'Allow'
AND (
any(action IN role_stmt.action WHERE action = '*')
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
)
CALL apoc.create.vRelationship(principal, 'CAN_LAUNCH', {
via: 'ec2:RunInstances + iam:PassRole'
}, ec2_node)
YIELD rel AS launch_rel
CALL apoc.create.vRelationship(ec2_node, 'ASSUMES_ROLE', {}, target_role)
YIELD rel AS assumes_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/ec2-001'
}, escalation_outcome)
YIELD rel AS grants_rel
UNWIND nodes(path_principal) + nodes(path_passrole) + nodes(path_ec2) + nodes(path_target) as n
OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding)
WHERE pf.status = 'FAIL'
RETURN path_principal, path_passrole, path_ec2, path_target,
ec2_node, escalation_outcome, launch_rel, assumes_rel, grants_rel,
collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-privesc-passrole-lambda",
name="Privilege Escalation: iam:PassRole + lambda:CreateFunction + lambda:InvokeFunction",
description="Detect principals who can create Lambda functions with privileged IAM roles and invoke them. This allows executing code with the permissions of the passed role. This is a new-passrole escalation path (pathfinding.cloud: lambda-001).",
provider="aws",
cypher="""
// Create a single shared virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-passrole-lambda',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Find statements granting iam:PassRole
MATCH path_passrole = (principal)--(passrole_policy:AWSPolicy)--(stmt_passrole:AWSPolicyStatement)
WHERE stmt_passrole.effect = 'Allow'
AND any(action IN stmt_passrole.action WHERE
toLower(action) = 'iam:passrole'
OR toLower(action) = 'iam:*'
OR action = '*'
)
// Find statements granting lambda:CreateFunction
MATCH path_create = (principal)--(create_policy:AWSPolicy)--(stmt_create:AWSPolicyStatement)
WHERE stmt_create.effect = 'Allow'
AND any(action IN stmt_create.action WHERE
toLower(action) = 'lambda:createfunction'
OR toLower(action) = 'lambda:*'
OR action = '*'
)
// Find statements granting lambda:InvokeFunction
MATCH path_invoke = (principal)--(invoke_policy:AWSPolicy)--(stmt_invoke:AWSPolicyStatement)
WHERE stmt_invoke.effect = 'Allow'
AND any(action IN stmt_invoke.action WHERE
toLower(action) = 'lambda:invokefunction'
OR toLower(action) = 'lambda:*'
OR action = '*'
)
// Find target roles with elevated permissions that could be passed
MATCH (aws)--(target_role:AWSRole)--(role_policy:AWSPolicy)--(role_stmt:AWSPolicyStatement)
WHERE role_stmt.effect = 'Allow'
AND (
any(action IN role_stmt.action WHERE action = '*')
OR any(action IN role_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate per (principal, target_role) pair
WITH DISTINCT escalation_outcome, aws, principal, target_role
// Group by principal, collect target_roles
WITH escalation_outcome, aws, principal,
collect(DISTINCT target_role) AS target_roles,
count(DISTINCT target_role) AS target_count
// Create single virtual Lambda function node per principal
CALL apoc.create.vNode(['LambdaFunction'], {
name: 'New Lambda Function',
description: toString(target_count) + ' admin role(s) can be passed',
id: principal.arn,
target_role_count: target_count
})
YIELD node AS lambda_node
CALL apoc.create.vRelationship(principal, 'CAN_CREATE_AND_INVOKE', {
via: 'lambda:CreateFunction + lambda:InvokeFunction + iam:PassRole'
}, lambda_node)
YIELD rel AS create_rel
// UNWIND target_roles to show which roles can be passed
UNWIND target_roles AS target_role
CALL apoc.create.vRelationship(lambda_node, 'PASSES_ROLE', {}, target_role)
YIELD rel AS pass_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/lambda-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
MATCH path_principal = (aws)--(principal)
RETURN path_principal,
lambda_node, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-iam-privesc-role-chain",
name="Privilege Escalation: Role Assumption Chains to Admin",
description="Detect multi-hop role assumption chains where a principal can reach an administrative role through one or more intermediate role assumptions. This traces STS_ASSUMEROLE_ALLOW relationships to find paths to privileged roles.",
provider="aws",
cypher="""
// Create a single shared virtual escalation outcome node (styled like a finding)
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-rolechain',
check_title: 'Privilege Escalation',
name: 'Effective Administrator',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Find role assumption chains (1-5 hops) to roles with elevated permissions
MATCH path_chain = (principal)-[:STS_ASSUMEROLE_ALLOW*1..5]->(target_role:AWSRole)
// Target role must have administrative permissions
MATCH path_admin = (target_role)--(admin_policy:AWSPolicy)--(admin_stmt:AWSPolicyStatement)
WHERE admin_stmt.effect = 'Allow'
AND (
any(action IN admin_stmt.action WHERE action = '*')
OR any(action IN admin_stmt.action WHERE toLower(action) = 'iam:*')
OR any(action IN admin_stmt.action WHERE toLower(action) CONTAINS 'admin')
)
// Deduplicate and calculate chain length before creating virtual relationships
WITH DISTINCT escalation_outcome, aws, principal, target_role, path_chain,
length(path_chain) as chain_length,
[node in nodes(path_chain) | node.name] as chain_nodes
CALL apoc.create.vRelationship(target_role, 'GRANTS_ADMIN', {
hops: chain_length,
technique: 'sts:AssumeRole chain (' + toString(chain_length) + ' hops)',
reference: 'https://pathfinding.cloud/paths/sts-001'
}, escalation_outcome)
YIELD rel AS admin_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
RETURN path_principal, path_chain,
escalation_outcome, admin_rel,
chain_length, chain_nodes
ORDER BY chain_length ASC
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-ecs-privesc-passrole-task",
name="Privilege Escalation: ECS Task Definition with PassRole",
description="Detect principals that can escalate privileges by passing a role to an ECS task definition and creating a service. The attacker can register a task definition with an arbitrary role, then access those role credentials from the running container.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-ecs',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (ECS)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops for flexibility)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, aws, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find ECS task definition permissions
MATCH (effective_principal)--(ecs_policy:AWSPolicy)--(ecs_stmt:AWSPolicyStatement)
WHERE ecs_stmt.effect = 'Allow'
AND (
any(action IN ecs_stmt.action WHERE toLower(action) = 'ecs:registertaskdefinition' OR action = '*' OR toLower(action) = 'ecs:*')
)
AND (
any(action IN ecs_stmt.action WHERE toLower(action) = 'ecs:createservice' OR toLower(action) = 'ecs:runtask' OR action = '*' OR toLower(action) = 'ecs:*')
)
// Find target roles with elevated permissions that could be passed
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate per (principal, target_role) pair
WITH DISTINCT escalation_outcome, aws, principal, target_role
// Group by principal, collect target_roles
WITH escalation_outcome, aws, principal,
collect(DISTINCT target_role) AS target_roles,
count(DISTINCT target_role) AS target_count
// Create single virtual ECS task node per principal
CALL apoc.create.vNode(['ECSTask'], {
name: 'New Task Definition',
description: toString(target_count) + ' admin role(s) can be passed',
id: principal.arn,
target_role_count: target_count
})
YIELD node AS ecs_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)
YIELD rel AS create_rel
// UNWIND target_roles to show which roles can be passed
UNWIND target_roles AS target_role
CALL apoc.create.vRelationship(ecs_task, 'PASSES_ROLE', {}, target_role)
YIELD rel AS pass_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/ecs-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
MATCH path_principal = (aws)--(principal)
RETURN path_principal,
ecs_task, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-ssm-privesc-start-session",
name="Privilege Escalation: SSM Start Session to EC2 Role",
description="Detect principals that can escalate privileges by using SSM StartSession to access an EC2 instance and inherit its IAM role credentials. This is an existing-passrole technique where the role is already attached to the instance.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-ssm',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (SSM)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find ssm:StartSession permission
MATCH (effective_principal)--(ssm_policy:AWSPolicy)--(ssm_stmt:AWSPolicyStatement)
WHERE ssm_stmt.effect = 'Allow'
AND any(action IN ssm_stmt.action WHERE toLower(action) = 'ssm:startsession' OR action = '*' OR toLower(action) = 'ssm:*')
// Find EC2 instances with instance profiles
MATCH (aws)--(instance:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(instance_role:AWSRole)
// Instance role should have elevated permissions
MATCH (instance_role)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
OR any(action IN target_stmt.action WHERE toLower(action) CONTAINS 'admin')
)
// Deduplicate before creating virtual relationships
WITH DISTINCT escalation_outcome, aws, principal, effective_principal, instance, instance_role
CALL apoc.create.vRelationship(effective_principal, 'SSM_ACCESS', {
permissions: ['ssm:StartSession'],
technique: 'existing-passrole'
}, instance)
YIELD rel AS ssm_rel
CALL apoc.create.vRelationship(instance_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/ssm-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match paths for visualization
MATCH path_principal = (aws)--(principal)
MATCH path_ec2 = (aws)--(instance)-[:STS_ASSUMEROLE_ALLOW]->(instance_role)
RETURN path_principal, path_ec2,
escalation_outcome, ssm_rel, grants_rel
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-glue-privesc-passrole-dev-endpoint",
name="Privilege Escalation: Glue Dev Endpoint with PassRole",
description="Detect principals that can escalate privileges by passing a role to a Glue development endpoint. The attacker creates a dev endpoint with an arbitrary role attached, then accesses those credentials through the endpoint.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-glue',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (Glue)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, aws, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find Glue CreateDevEndpoint permission
MATCH (effective_principal)--(glue_policy:AWSPolicy)--(glue_stmt:AWSPolicyStatement)
WHERE glue_stmt.effect = 'Allow'
AND any(action IN glue_stmt.action WHERE toLower(action) = 'glue:createdevendpoint' OR action = '*' OR toLower(action) = 'glue:*')
// Find target roles with elevated permissions that could be passed
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate per (principal, target_role) pair
WITH DISTINCT escalation_outcome, aws, principal, target_role
// Group by principal, collect target_roles
WITH escalation_outcome, aws, principal,
collect(DISTINCT target_role) AS target_roles,
count(DISTINCT target_role) AS target_count
// Create single virtual Glue endpoint node per principal
CALL apoc.create.vNode(['GlueDevEndpoint'], {
name: 'New Dev Endpoint',
description: toString(target_count) + ' admin role(s) can be passed',
id: principal.arn,
target_role_count: target_count
})
YIELD node AS glue_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)
YIELD rel AS create_rel
// UNWIND target_roles to show which roles can be passed
UNWIND target_roles AS target_role
CALL apoc.create.vRelationship(glue_endpoint, 'PASSES_ROLE', {}, target_role)
YIELD rel AS pass_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/glue-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
MATCH path_principal = (aws)--(principal)
RETURN path_principal,
glue_endpoint, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-bedrock-privesc-passrole-code-interpreter",
name="Privilege Escalation: Bedrock Code Interpreter with PassRole",
@@ -515,6 +1069,92 @@ _QUERY_DEFINITIONS: dict[str, list[AttackPathsQueryDefinition]] = {
""",
parameters=[],
),
AttackPathsQueryDefinition(
id="aws-cloudformation-privesc-passrole-create-stack",
name="Privilege Escalation: CloudFormation Stack with PassRole",
description="Detect principals that can escalate privileges by passing a role to a CloudFormation stack. The attacker creates a stack with an arbitrary role, allowing CloudFormation to perform actions as that role when creating resources.",
provider="aws",
cypher="""
CALL apoc.create.vNode(['PrivilegeEscalation'], {
id: 'effective-administrator-cfn',
check_title: 'Privilege Escalation',
name: 'Effective Administrator (CloudFormation)',
status: 'FAIL',
severity: 'critical'
})
YIELD node AS escalation_outcome
WITH escalation_outcome
// Find principals in the account
MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)
// Principal can assume roles (up to 2 hops)
OPTIONAL MATCH path_assume = (principal)-[:STS_ASSUMEROLE_ALLOW*0..2]->(acting_as:AWSRole)
WITH escalation_outcome, aws, principal, path_principal, path_assume,
CASE WHEN path_assume IS NULL THEN principal ELSE acting_as END AS effective_principal
// Find iam:PassRole permission
MATCH path_passrole = (effective_principal)--(passrole_policy:AWSPolicy)--(passrole_stmt:AWSPolicyStatement)
WHERE passrole_stmt.effect = 'Allow'
AND any(action IN passrole_stmt.action WHERE toLower(action) = 'iam:passrole' OR action = '*')
// Find CloudFormation CreateStack permission
MATCH (effective_principal)--(cfn_policy:AWSPolicy)--(cfn_stmt:AWSPolicyStatement)
WHERE cfn_stmt.effect = 'Allow'
AND any(action IN cfn_stmt.action WHERE toLower(action) = 'cloudformation:createstack' OR action = '*' OR toLower(action) = 'cloudformation:*')
// Find target roles with elevated permissions that could be passed
MATCH (aws)--(target_role:AWSRole)--(target_policy:AWSPolicy)--(target_stmt:AWSPolicyStatement)
WHERE target_stmt.effect = 'Allow'
AND (
any(action IN target_stmt.action WHERE action = '*')
OR any(action IN target_stmt.action WHERE toLower(action) = 'iam:*')
)
// Deduplicate per (principal, target_role) pair
WITH DISTINCT escalation_outcome, aws, principal, target_role
// Group by principal, collect target_roles
WITH escalation_outcome, aws, principal,
collect(DISTINCT target_role) AS target_roles,
count(DISTINCT target_role) AS target_count
// Create single virtual CloudFormation stack node per principal
CALL apoc.create.vNode(['CloudFormationStack'], {
name: 'New Stack',
description: toString(target_count) + ' admin role(s) can be passed',
id: principal.arn,
target_role_count: target_count
})
YIELD node AS cfn_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)
YIELD rel AS create_rel
// UNWIND target_roles to show which roles can be passed
UNWIND target_roles AS target_role
CALL apoc.create.vRelationship(cfn_stack, 'PASSES_ROLE', {}, target_role)
YIELD rel AS pass_rel
CALL apoc.create.vRelationship(target_role, 'GRANTS_ACCESS', {
reference: 'https://pathfinding.cloud/paths/cloudformation-001'
}, escalation_outcome)
YIELD rel AS grants_rel
// Re-match path for visualization (only principal path, target_role is connected via virtual rels)
MATCH path_principal = (aws)--(principal)
RETURN path_principal,
cfn_stack, target_role, escalation_outcome, create_rel, pass_rel, grants_rel, target_count
""",
parameters=[],
),
],
}

View File

@@ -1,7 +1,7 @@
services:
api-dev:
hostname: "prowler-api"
image: prowler-api-dev
# image: prowler-api-dev
build:
context: ./api
dockerfile: Dockerfile
@@ -115,7 +115,7 @@ services:
retries: 10
worker-dev:
image: prowler-api-dev
# image: prowler-api-dev
build:
context: ./api
dockerfile: Dockerfile
@@ -142,7 +142,7 @@ services:
- "worker"
worker-beat:
image: prowler-api-dev
# image: prowler-api-dev
build:
context: ./api
dockerfile: Dockerfile

View File

@@ -175,63 +175,47 @@ function generateLegendItems(
hasFindings: boolean,
): LegendItem[] {
const items: LegendItem[] = [];
const seenTypes = new Set<string>();
// Add severity items if there are findings
if (hasFindings) {
items.push(...severityLegendItems);
}
// Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement")
const formatNodeTypeName = (nodeType: string): string => {
return nodeType
.replace(/([A-Z])/g, " $1") // Add space before capitals
.replace(/^ /, "") // Remove leading space
.replace(/AWS /g, "AWS ") // Keep AWS together
.replace(/EC2 /g, "EC2 ") // Keep EC2 together
.replace(/S3 /g, "S3 ") // Keep S3 together
.replace(/IAM /g, "IAM ") // Keep IAM together
.replace(/IP /g, "IP ") // Keep IP together
.trim();
};
// Check for Internet node
const hasInternet = nodeTypes.some(
(type) => type.toLowerCase() === "internet",
);
nodeTypes.forEach((nodeType) => {
if (seenTypes.has(nodeType)) return;
seenTypes.add(nodeType);
// Skip findings - we show severity colors instead
const isFinding = nodeType.toLowerCase().includes("finding");
if (isFinding) return;
const description = nodeTypeDescriptions[nodeType];
// Determine shape based on node type
const isInternet = nodeType.toLowerCase() === "internet";
const shape: "rectangle" | "hexagon" | "cloud" = isInternet
? "cloud"
: "rectangle";
if (description) {
items.push({
label: description.name,
color: getNodeColor([nodeType]),
borderColor: getNodeBorderColor([nodeType]),
description: description.description,
shape,
});
} else {
// Format unknown node types nicely
const formattedName = formatNodeTypeName(nodeType);
items.push({
label: formattedName,
color: getNodeColor([nodeType]),
borderColor: getNodeBorderColor([nodeType]),
description: `${formattedName} node`,
shape,
});
}
// Check for any resource nodes (non-finding, non-internet)
const hasResources = nodeTypes.some((type) => {
const isFinding = type.toLowerCase().includes("finding");
const isPrivilegeEscalation = type === "PrivilegeEscalation";
const isInternet = type.toLowerCase() === "internet";
return !isFinding && !isPrivilegeEscalation && !isInternet;
});
// Add a single "Resource" item for all resource types
if (hasResources) {
items.push({
label: "Resource",
color: GRAPH_NODE_COLORS.default,
borderColor: GRAPH_NODE_BORDER_COLORS.default,
description: "Cloud infrastructure resource",
shape: "rectangle",
});
}
// Add Internet node if present
if (hasInternet) {
items.push({
label: "Internet",
color: getNodeColor(["Internet"]),
borderColor: getNodeBorderColor(["Internet"]),
description: "Internet gateway or public access",
shape: "cloud",
});
}
return items;
}
@@ -395,9 +379,10 @@ interface GraphLegendProps {
export const GraphLegend = ({ data }: GraphLegendProps) => {
const nodeTypes = extractNodeTypes(data?.nodes);
// Check if there are any findings in the data
const hasFindings = nodeTypes.some((type) =>
type.toLowerCase().includes("finding"),
// Check if there are any findings or privilege escalations in the data
const hasFindings = nodeTypes.some(
(type) =>
type.toLowerCase().includes("finding") || type === "PrivilegeEscalation",
);
const legendItems = generateLegendItems(nodeTypes, hasFindings);

View File

@@ -8,17 +8,29 @@ import type {
GraphState,
} from "@/types/attack-paths";
interface GraphStore extends GraphState {
interface FilteredViewState {
isFilteredView: boolean;
filteredNodeId: string | null;
fullData: AttackPathGraphData | null; // Original data before filtering
}
interface GraphStore extends GraphState, FilteredViewState {
setGraphData: (data: AttackPathGraphData) => void;
setSelectedNodeId: (nodeId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setZoom: (zoomLevel: number) => void;
setPan: (panX: number, panY: number) => void;
setFilteredView: (
isFiltered: boolean,
nodeId: string | null,
filteredData: AttackPathGraphData | null,
fullData: AttackPathGraphData | null,
) => void;
reset: () => void;
}
const initialState: GraphState = {
const initialState: GraphState & FilteredViewState = {
data: null,
selectedNodeId: null,
loading: false,
@@ -26,22 +38,136 @@ const initialState: GraphState = {
zoomLevel: 1,
panX: 0,
panY: 0,
isFilteredView: false,
filteredNodeId: null,
fullData: null,
};
const useGraphStore = create<GraphStore>((set) => ({
...initialState,
setGraphData: (data) => set({ data, error: null }),
setGraphData: (data) => set({ data, fullData: null, error: null, isFilteredView: false, filteredNodeId: null }),
setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
setZoom: (zoomLevel) => set({ zoomLevel }),
setPan: (panX, panY) => set({ panX, panY }),
setFilteredView: (isFiltered, nodeId, filteredData, fullData) =>
set({ isFilteredView: isFiltered, filteredNodeId: nodeId, data: filteredData, fullData, selectedNodeId: nodeId }),
reset: () => set(initialState),
}));
/**
* Helper to get edge source/target ID from string or object
*/
function getEdgeNodeId(nodeRef: string | object): string {
if (typeof nodeRef === "string") {
return nodeRef;
}
return (nodeRef as GraphNode).id;
}
/**
* Compute a filtered subgraph containing only the path through the target node.
* This follows the directed graph structure of attack paths:
* - Upstream: traces back to the root (AWS Account)
* - Downstream: traces forward to leaf nodes
* - Also includes findings connected to the selected node
*/
function computeFilteredSubgraph(
fullData: AttackPathGraphData,
targetNodeId: string,
): AttackPathGraphData {
const nodes = fullData.nodes;
const edges = fullData.edges || [];
// Build directed adjacency lists
const forwardEdges = new Map<string, Set<string>>(); // source -> targets
const backwardEdges = new Map<string, Set<string>>(); // target -> sources
nodes.forEach((node) => {
forwardEdges.set(node.id, new Set());
backwardEdges.set(node.id, new Set());
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
});
const visibleNodeIds = new Set<string>();
visibleNodeIds.add(targetNodeId);
// Traverse upstream (backward) - find all ancestors
const traverseUpstream = (nodeId: string) => {
const sources = backwardEdges.get(nodeId);
if (sources) {
sources.forEach((sourceId) => {
if (!visibleNodeIds.has(sourceId)) {
visibleNodeIds.add(sourceId);
traverseUpstream(sourceId);
}
});
}
};
// Traverse downstream (forward) - find all descendants
const traverseDownstream = (nodeId: string) => {
const targets = forwardEdges.get(nodeId);
if (targets) {
targets.forEach((targetId) => {
if (!visibleNodeIds.has(targetId)) {
visibleNodeIds.add(targetId);
traverseDownstream(targetId);
}
});
}
};
// Start traversal from the target node
traverseUpstream(targetNodeId);
traverseDownstream(targetNodeId);
// Also include findings directly connected to the selected node
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceIsFinding = sourceNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
);
const targetIsFinding = targetNode?.labels.some((l) =>
l.toLowerCase().includes("finding"),
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
visibleNodeIds.add(targetId);
}
if (targetId === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(sourceId);
}
});
// Filter nodes and edges to only include visible ones
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
return {
nodes: filteredNodes,
edges: filteredEdges,
};
}
/**
* Custom hook for managing graph visualization state
* Handles graph data, node selection, zoom/pan, and loading states
* Handles graph data, node selection, zoom/pan, loading states, and filtered view
*/
export const useGraphState = () => {
const store = useGraphStore();
@@ -86,10 +212,46 @@ export const useGraphState = () => {
const clearGraph = () => {
store.setGraphData({ nodes: [], edges: [] });
store.setSelectedNodeId(null);
store.setFilteredView(false, null, null, null);
};
/**
* Enter filtered view mode - redraws graph with only the selected path
* Stores full data so we can restore it when exiting filtered view
*/
const enterFilteredView = (nodeId: string) => {
if (!store.data) return;
// Use fullData if we're already in filtered view, otherwise use current data
const sourceData = store.fullData || store.data;
const filteredData = computeFilteredSubgraph(sourceData, nodeId);
store.setFilteredView(true, nodeId, filteredData, sourceData);
};
/**
* Exit filtered view mode - restore full graph data
*/
const exitFilteredView = () => {
if (!store.isFilteredView || !store.fullData) return;
store.setFilteredView(false, null, store.fullData, null);
};
/**
* Get the node that was used to filter the view
*/
const getFilteredNode = (): GraphNode | null => {
if (!store.isFilteredView || !store.filteredNodeId) return null;
// Look in fullData since that's where the original node data is
const sourceData = store.fullData || store.data;
if (!sourceData) return null;
return (
sourceData.nodes.find((node) => node.id === store.filteredNodeId) || null
);
};
return {
data: store.data,
fullData: store.fullData,
selectedNodeId: store.selectedNodeId,
selectedNode: getSelectedNode(),
loading: store.loading,
@@ -97,6 +259,9 @@ export const useGraphState = () => {
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
updateGraphData,
selectNode,
startLoading,
@@ -105,5 +270,7 @@ export const useGraphState = () => {
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,
exitFilteredView,
};
};

View File

@@ -66,7 +66,9 @@ export const getNodeColor = (
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
if (isFinding && properties?.severity) {
const isPrivilegeEscalation = labels.some((l) => l === "PrivilegeEscalation");
if ((isFinding || isPrivilegeEscalation) && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_COLORS.critical;
if (severity === "high") return GRAPH_NODE_COLORS.high;
@@ -99,7 +101,9 @@ export const getNodeBorderColor = (
properties?: Record<string, unknown>,
): string => {
const isFinding = labels.some((l) => l.toLowerCase().includes("finding"));
if (isFinding && properties?.severity) {
const isPrivilegeEscalation = labels.some((l) => l === "PrivilegeEscalation");
if ((isFinding || isPrivilegeEscalation) && properties?.severity) {
const severity = String(properties.severity).toLowerCase();
if (severity === "critical") return GRAPH_NODE_BORDER_COLORS.critical;
if (severity === "high") return GRAPH_NODE_BORDER_COLORS.high;

View File

@@ -7,6 +7,8 @@
* Upstream: follows only ONE parent path (first parent at each level) to avoid lighting up siblings
* Downstream: follows ALL children recursively
*
* Uses pre-built adjacency maps for O(1) lookups instead of O(n) array searches per traversal step.
*
* @param nodeId - The starting node ID
* @param edges - Array of edges with sourceId and targetId
* @returns Set of edge IDs in the format "sourceId-targetId"
@@ -15,6 +17,23 @@ export const getPathEdges = (
nodeId: string,
edges: Array<{ sourceId: string; targetId: string }>,
): Set<string> => {
// Build adjacency maps once - O(n)
const parentMap = new Map<string, { sourceId: string; targetId: string }>();
const childrenMap = new Map<
string,
Array<{ sourceId: string; targetId: string }>
>();
edges.forEach((edge) => {
// First parent only (matches original behavior of find())
if (!parentMap.has(edge.targetId)) {
parentMap.set(edge.targetId, edge);
}
const children = childrenMap.get(edge.sourceId) || [];
children.push(edge);
childrenMap.set(edge.sourceId, children);
});
const pathEdgeIds = new Set<string>();
const visitedNodes = new Set<string>();
@@ -24,8 +43,7 @@ export const getPathEdges = (
if (visitedNodes.has(`up-${currentNodeId}`)) return;
visitedNodes.add(`up-${currentNodeId}`);
// Find the first parent edge only
const parentEdge = edges.find((edge) => edge.targetId === currentNodeId);
const parentEdge = parentMap.get(currentNodeId); // O(1) lookup
if (parentEdge) {
pathEdgeIds.add(`${parentEdge.sourceId}-${parentEdge.targetId}`);
traverseUpstream(parentEdge.sourceId);
@@ -37,11 +55,10 @@ export const getPathEdges = (
if (visitedNodes.has(`down-${currentNodeId}`)) return;
visitedNodes.add(`down-${currentNodeId}`);
edges.forEach((edge) => {
if (edge.sourceId === currentNodeId) {
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
traverseDownstream(edge.targetId);
}
const children = childrenMap.get(currentNodeId) || []; // O(1) lookup
children.forEach((edge) => {
pathEdgeIds.add(`${edge.sourceId}-${edge.targetId}`);
traverseDownstream(edge.targetId);
});
};

View File

@@ -1,6 +1,6 @@
"use client";
import { Maximize2, X } from "lucide-react";
import { ArrowLeft, Maximize2, X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
@@ -233,15 +233,15 @@ export default function AttackPathAnalysisPage() {
};
const handleNodeClick = (node: GraphNode) => {
graphState.selectNode(node.id);
// Enter filtered view showing only paths containing this node
graphState.enterFilteredView(node.id);
// Only scroll to details if it's a finding node
// For findings, also scroll to the details section
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
// Scroll to node details section after a short delay
setTimeout(() => {
nodeDetailsRef.current?.scrollIntoView({
behavior: "smooth",
@@ -251,6 +251,10 @@ export default function AttackPathAnalysisPage() {
}
};
const handleBackToFullView = () => {
graphState.exitFilteredView();
};
const handleCloseDetails = () => {
graphState.selectNode(null);
};
@@ -371,18 +375,50 @@ export default function AttackPathAnalysisPage() {
<>
{/* Info message and controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any resource node to view its related findings
</span>
</div>
{graphState.isFilteredView ? (
<div className="flex items-center gap-3">
<Button
onClick={handleBackToFullView}
variant="outline"
size="sm"
className="gap-2"
aria-label="Return to full graph view"
>
<ArrowLeft size={16} />
Back to Full View
</Button>
<div
className="bg-bg-info-secondary text-text-info inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Filtered view active"
>
<span className="flex-shrink-0" aria-hidden="true">
🔍
</span>
<span className="flex-1">
Showing paths for:{" "}
<strong>
{graphState.filteredNode?.properties?.name ||
graphState.filteredNode?.properties?.id ||
"Selected node"}
</strong>
</span>
</div>
</div>
) : (
<div
className="bg-button-primary inline-flex cursor-default items-center gap-2 rounded-md px-3 py-2 text-xs font-medium text-black shadow-sm sm:px-4 sm:text-sm"
role="status"
aria-label="Graph interaction instructions"
>
<span className="flex-shrink-0" aria-hidden="true">
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected paths
</span>
</div>
)}
{/* Graph controls and fullscreen button together */}
<div className="flex items-center gap-2">
@@ -441,6 +477,7 @@ export default function AttackPathAnalysisPage() {
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>
{/* Node Detail Panel - Side by side */}
@@ -509,6 +546,7 @@ export default function AttackPathAnalysisPage() {
data={graphState.data}
onNodeClick={handleNodeClick}
selectedNodeId={graphState.selectedNodeId}
isFilteredView={graphState.isFilteredView}
/>
</div>

View File

@@ -42,10 +42,10 @@
{
"section": "dependencies",
"name": "@langchain/core",
"from": "0.3.77",
"to": "0.3.78",
"from": "0.3.78",
"to": "0.3.77",
"strategy": "installed",
"generatedAt": "2025-11-03T07:43:34.628Z"
"generatedAt": "2026-01-07T08:46:39.109Z"
},
{
"section": "dependencies",
@@ -415,6 +415,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "require-in-the-middle",
"from": "8.0.1",
"to": "8.0.1",
"strategy": "installed",
"generatedAt": "2026-01-07T12:09:03.204Z"
},
{
"section": "dependencies",
"name": "rss-parser",

55
ui/package-lock.json generated
View File

@@ -61,6 +61,7 @@
"react-hook-form": "7.62.0",
"react-markdown": "10.1.0",
"recharts": "2.15.4",
"require-in-the-middle": "8.0.1",
"rss-parser": "3.13.0",
"server-only": "0.0.1",
"sharp": "0.33.5",
@@ -5273,7 +5274,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz",
"integrity": "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.203.0",
"import-in-the-middle": "^1.8.1",
@@ -5495,6 +5495,20 @@
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-ioredis/node_modules/require-in-the-middle": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3",
"resolve": "^1.22.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/@opentelemetry/instrumentation-kafkajs": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.13.0.tgz",
@@ -5696,6 +5710,20 @@
"@opentelemetry/api": "^1.7.0"
}
},
"node_modules/@opentelemetry/instrumentation/node_modules/require-in-the-middle": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3",
"resolve": "^1.22.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/@opentelemetry/redis-common": {
"version": "0.38.2",
"resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz",
@@ -5848,6 +5876,20 @@
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@prisma/instrumentation/node_modules/require-in-the-middle": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3",
"resolve": "^1.22.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/@prisma/instrumentation/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -22881,17 +22923,16 @@
}
},
"node_modules/require-in-the-middle": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3",
"resolve": "^1.22.8"
"module-details-from-path": "^1.0.3"
},
"engines": {
"node": ">=8.6.0"
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/resolve": {

View File

@@ -75,6 +75,7 @@
"react-hook-form": "7.62.0",
"react-markdown": "10.1.0",
"recharts": "2.15.4",
"require-in-the-middle": "8.0.1",
"rss-parser": "3.13.0",
"server-only": "0.0.1",
"sharp": "0.33.5",